diff --git a/.gitignore b/.gitignore index 3d6e7f2531..7bdbfcca08 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ .DS_Store .codegraph .tracedecay -.mcp.json !plugin/.mcp.json .claude/settings.local.json .cursor/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..046a84a15e --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "hotpath": { + "type": "http", + "url": "http://127.0.0.1:6771/mcp" + } + } +} diff --git a/Cargo.lock b/Cargo.lock index 611717ea8c..38d6c405c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6337,6 +6337,7 @@ dependencies = [ "hex", "hmac 0.13.0", "hotpath", + "rayon", "roaring", "rusqlite", "serde", @@ -6360,6 +6361,7 @@ dependencies = [ name = "tracedecay-runtime-core" version = "0.1.0" dependencies = [ + "aho-corasick", "amari-holographic", "chrono", "criterion", diff --git a/crates/tracedecay-code-index/src/extract.rs b/crates/tracedecay-code-index/src/extract.rs index d965fef6dd..b739d6598b 100644 --- a/crates/tracedecay-code-index/src/extract.rs +++ b/crates/tracedecay-code-index/src/extract.rs @@ -199,7 +199,6 @@ impl TreeSitterExtractor { }) } - #[hotpath::measure] pub(crate) fn extract_preparsed( &self, file: &ReceiptBoundCodeFileV1, @@ -208,30 +207,32 @@ impl TreeSitterExtractor { parsed_len: usize, cancellation: &dyn ExtractionCancellation, ) -> Result { - if cancellation.is_cancelled() { - return Err(ExtractionFailureV1::Cancelled); - } - let authority = file.authority().clone(); - let file = file.validated_file(); - validate_descriptor(file, descriptor)?; - let admitted_prefix = file - .sanitized_bytes - .get(..parsed_len) - .and_then(|bytes| std::str::from_utf8(bytes).ok()); - if admitted_prefix.is_none() { - return Err(ExtractionFailureV1::ParseFailed { - detail: "retained parse prefix is not an admitted UTF-8 boundary".to_owned(), - }); - } - finish_extraction( - authority, - file, - descriptor, - artifact, - parsed_len, - parsed_len < file.sanitized_bytes.len(), - cancellation, - ) + crate::hotpath_observe::measure_hot_loop!("code_index.extract.incremental", { + if cancellation.is_cancelled() { + return Err(ExtractionFailureV1::Cancelled); + } + let authority = file.authority().clone(); + let file = file.validated_file(); + validate_descriptor(file, descriptor)?; + let admitted_prefix = file + .sanitized_bytes + .get(..parsed_len) + .and_then(|bytes| std::str::from_utf8(bytes).ok()); + if admitted_prefix.is_none() { + return Err(ExtractionFailureV1::ParseFailed { + detail: "retained parse prefix is not an admitted UTF-8 boundary".to_owned(), + }); + } + finish_extraction( + authority, + file, + descriptor, + artifact, + parsed_len, + parsed_len < file.sanitized_bytes.len(), + cancellation, + ) + }) } /// Typed evidence for one file whose bounded retained parse exceeded its @@ -441,7 +442,7 @@ fn rows_digest( .map(CanonicalUnresolvedRefRow::from) .collect::>(); let mut imports = artifact.imports.clone(); - hotpath::measure_block!("code_index_rows_digest_sort", { + crate::hotpath_observe::measure_hot_loop!("code_index_rows_digest_sort", { sort_canonical_rows(&mut nodes); sort_canonical_rows(&mut edges); sort_canonical_rows(&mut unresolved); @@ -462,7 +463,7 @@ fn rows_digest( unresolved_refs: Vec>, } - hotpath::measure_block!( + crate::hotpath_observe::measure_hot_loop!( "code_index_rows_digest_hash", canonical_sha256(&RowsPayload { separator: EXTRACTION_ROWS_SEPARATOR, @@ -485,7 +486,7 @@ fn rows_digest( pub(crate) fn parser_import_rows_digest( imports: &[ExtractedImportEvidenceV1], ) -> Result { - hotpath::measure_block!("code_index_parser_import_rows_digest", { + crate::hotpath_observe::measure_hot_loop!("code_index_parser_import_rows_digest", { let mut imports = imports.to_vec(); imports.sort(); canonical_sha256(&(PARSER_IMPORT_ROWS_DIGEST_SEPARATOR, imports.as_slice())).map_err( @@ -497,55 +498,56 @@ pub(crate) fn parser_import_rows_digest( } impl LanguageExtractor for TreeSitterExtractor { - #[hotpath::measure] fn extract( &self, file: &ReceiptBoundCodeFileV1, descriptor: &LanguageDescriptorV1, cancellation: &dyn ExtractionCancellation, ) -> Result { - if cancellation.is_cancelled() { - return Err(ExtractionFailureV1::Cancelled); - } - let authority = file.authority().clone(); - let file = file.validated_file(); - validate_descriptor(file, descriptor)?; - - let parser = self.resolve_parser(file, descriptor).ok_or({ - ExtractionFailureV1::GrammarUnavailable { - language: descriptor.language.clone(), + crate::hotpath_observe::measure_hot_loop!("code_index.extract.full", { + if cancellation.is_cancelled() { + return Err(ExtractionFailureV1::Cancelled); } - })?; - if canonical_language_id(parser.language_name()) != descriptor.language.as_str() { - return Err(ExtractionFailureV1::IncompatibleDescriptor { - detail: format!( - "descriptor {} resolved to a {} parser", - descriptor.language, - parser.language_name() - ), - }); - } - - let source = std::str::from_utf8(&file.sanitized_bytes).map_err(|error| { - ExtractionFailureV1::ParseFailed { - detail: format!("sanitized bytes are not valid UTF-8: {error}"), + let authority = file.authority().clone(); + let file = file.validated_file(); + validate_descriptor(file, descriptor)?; + + let parser = self.resolve_parser(file, descriptor).ok_or({ + ExtractionFailureV1::GrammarUnavailable { + language: descriptor.language.clone(), + } + })?; + if canonical_language_id(parser.language_name()) != descriptor.language.as_str() { + return Err(ExtractionFailureV1::IncompatibleDescriptor { + detail: format!( + "descriptor {} resolved to a {} parser", + descriptor.language, + parser.language_name() + ), + }); } - })?; - let parsed_len = - crate::chunks::snap_down(source, source.len().min(MAX_EXTRACTION_SOURCE_BYTES)); - let extraction_source = &source[..parsed_len]; - let source_was_capped = parsed_len < source.len(); - - let artifact = parser.extract_artifact(&file.file.logical_path, extraction_source); - finish_extraction( - authority, - file, - descriptor, - artifact, - parsed_len, - source_was_capped, - cancellation, - ) + + let source = std::str::from_utf8(&file.sanitized_bytes).map_err(|error| { + ExtractionFailureV1::ParseFailed { + detail: format!("sanitized bytes are not valid UTF-8: {error}"), + } + })?; + let parsed_len = + crate::chunks::snap_down(source, source.len().min(MAX_EXTRACTION_SOURCE_BYTES)); + let extraction_source = &source[..parsed_len]; + let source_was_capped = parsed_len < source.len(); + + let artifact = parser.extract_artifact(&file.file.logical_path, extraction_source); + finish_extraction( + authority, + file, + descriptor, + artifact, + parsed_len, + source_was_capped, + cancellation, + ) + }) } } diff --git a/crates/tracedecay-code-index/src/generations.rs b/crates/tracedecay-code-index/src/generations.rs index 334c8963fa..7efa3d6bc9 100644 --- a/crates/tracedecay-code-index/src/generations.rs +++ b/crates/tracedecay-code-index/src/generations.rs @@ -270,7 +270,7 @@ impl GenerationPlanner { /// Plan and seal one immutable generation while binding every inferred and /// explicitly declared rebuild cause into its identity and publication /// fence. - #[hotpath::measure] + #[hotpath::measure(label = "code_index.build.plan_full")] pub fn plan_generation_with_invalidation( &self, snapshot: &ValidatedCodeSnapshotV1, @@ -364,7 +364,7 @@ impl GenerationPlanner { /// quarantined corruption are not inferable from a sanitized snapshot, /// so callers must declare them explicitly. Declared reasons are merged /// with descriptor, sanitizer, chunker, and privacy incompatibilities. - #[hotpath::measure] + #[hotpath::measure(label = "code_index.build.plan_increment")] pub fn plan_increment_with_invalidation( &self, prior_manifest: &CodeGenerationManifestV1, @@ -681,7 +681,7 @@ pub enum GenerationJoinErrorV1 { /// every eligible chunk names exactly one code generation and file /// occurrence). Cross-generation documents or chunks, undeclared chunks, and /// duplicates are typed rejections — never silently joined. -#[hotpath::measure] +#[hotpath::measure(label = "code_index.build.generation_join")] pub fn join_chunks_to_generation( generation: &CodeGenerationManifestV1, document: &CodeSearchDocumentV1, diff --git a/crates/tracedecay-code-index/src/hotpath_observe.rs b/crates/tracedecay-code-index/src/hotpath_observe.rs index ad8757f2e2..03dd2000b6 100644 --- a/crates/tracedecay-code-index/src/hotpath_observe.rs +++ b/crates/tracedecay-code-index/src/hotpath_observe.rs @@ -17,7 +17,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; #[cfg(feature = "hotpath")] use std::time::Instant; -#[cfg(feature = "hotpath")] +#[cfg(any(feature = "hotpath", test))] const HOT_LOOP_SAMPLE_PERIOD: u64 = 32; #[cfg(feature = "hotpath")] @@ -27,16 +27,21 @@ static WORKERS_ACTIVE: AtomicUsize = AtomicUsize::new(0); #[cfg(feature = "hotpath")] static WORKERS_POOL_COORDINATION: AtomicUsize = AtomicUsize::new(0); #[cfg(feature = "hotpath")] -static GREP_FILE_SAMPLE: AtomicU64 = AtomicU64::new(0); +static HOT_LOOP_SAMPLE: AtomicU64 = AtomicU64::new(0); + +#[cfg(feature = "hotpath")] +#[must_use] +#[inline(always)] +fn is_hot_loop_sample(sequence: u64) -> bool { + sequence.is_multiple_of(HOT_LOOP_SAMPLE_PERIOD) +} #[must_use] #[inline(always)] pub(crate) fn sample_hot_loop() -> bool { #[cfg(feature = "hotpath")] { - GREP_FILE_SAMPLE - .fetch_add(1, Ordering::Relaxed) - .is_multiple_of(HOT_LOOP_SAMPLE_PERIOD) + is_hot_loop_sample(HOT_LOOP_SAMPLE.fetch_add(1, Ordering::Relaxed)) } #[cfg(not(feature = "hotpath"))] { @@ -44,6 +49,24 @@ pub(crate) fn sample_hot_loop() -> bool { } } +/// Measure one fixed-rate sample from a hot file loop. +/// +/// The label must be a literal so callers cannot create path- or +/// generation-shaped cardinality. The disabled path still evaluates only the +/// work expression; its sampler is an inlined constant `false` with no atomic +/// or clock access. +macro_rules! measure_hot_loop { + ($label:literal, $work:expr) => {{ + if $crate::hotpath_observe::sample_hot_loop() { + hotpath::measure_block!($label, $work) + } else { + $work + } + }}; +} + +pub(crate) use measure_hot_loop; + #[cfg(any(feature = "hotpath", test))] #[inline(always)] fn decrement_if_positive(counter: &AtomicUsize) -> bool { @@ -297,21 +320,26 @@ pub(crate) fn record_seal_bytes(bytes: u64) { } } -pub(crate) struct TtfqStart(#[cfg(feature = "hotpath")] Instant); +/// Start of one production-owner generation build. The matching observation +/// ends only after the immutable generation has been published and is +/// queryable through that owner; daemon scheduling/wake latency is measured +/// separately by the reconcile cadence receipt. +pub(crate) struct BuildToQueryableStart(#[cfg(feature = "hotpath")] Instant); #[inline(always)] -pub(crate) fn start_ttfq() -> TtfqStart { - TtfqStart( +pub(crate) fn start_build_to_queryable() -> BuildToQueryableStart { + BuildToQueryableStart( #[cfg(feature = "hotpath")] Instant::now(), ) } #[inline(always)] -pub(crate) fn record_ttfq(started: TtfqStart) { +pub(crate) fn record_build_to_queryable(started: BuildToQueryableStart) { #[cfg(feature = "hotpath")] { - hotpath::gauge!("code_index_ttfq_micros").set(started.0.elapsed().as_micros() as f64); + hotpath::gauge!("code_index_build_to_queryable_micros") + .set(started.0.elapsed().as_micros() as f64); } #[cfg(not(feature = "hotpath"))] { @@ -347,6 +375,22 @@ pub(crate) fn record_rebuild_state(state: &'static str) { mod tests { use super::*; + #[cfg(feature = "hotpath")] + #[test] + fn hotpath_file_probe_cadence_uses_one_fixed_slot_per_period() { + let sampled = (0..(HOT_LOOP_SAMPLE_PERIOD * 2)) + .filter(|sequence| is_hot_loop_sample(*sequence)) + .collect::>(); + + assert_eq!(sampled, vec![0, HOT_LOOP_SAMPLE_PERIOD]); + } + + #[cfg(not(feature = "hotpath"))] + #[test] + fn hotpath_file_probe_sampler_is_dormant_without_the_feature() { + assert!((0..(HOT_LOOP_SAMPLE_PERIOD * 2)).all(|_| !sample_hot_loop())); + } + #[test] fn pending_queue_decrements_when_each_worker_starts() { let queue = PendingWorkQueue::new(3); diff --git a/crates/tracedecay-code-index/src/intake.rs b/crates/tracedecay-code-index/src/intake.rs index 7100483c71..502bd84c6c 100644 --- a/crates/tracedecay-code-index/src/intake.rs +++ b/crates/tracedecay-code-index/src/intake.rs @@ -253,51 +253,58 @@ impl CodeIndexIntake for SanitizedCodeIntake { &self, snapshot: SanitizedCodeSnapshotV1, ) -> Result { - self.validate_snapshot(snapshot) + hotpath::measure_block!( + "code_index.intake.validation", + self.validate_snapshot(snapshot) + ) } fn admit( &self, snapshot: SanitizedCodeSnapshotV1, ) -> Result { - self.validate_snapshot(snapshot) - .map(SanitizedSnapshotCapabilityV1::new) + hotpath::measure_block!( + "code_index.intake.admission", + self.validate_snapshot(snapshot) + .map(SanitizedSnapshotCapabilityV1::new) + ) } - #[hotpath::measure] fn bind_file( &self, capability: &SanitizedSnapshotCapabilityV1, project_id: &ProjectId, file: ValidatedCodeFileV1, ) -> Result { - if project_id.validate().is_err() { - return Err(IntakeRejectionV1::UnsanitizedInput); - } - if file.snapshot_digest != capability.snapshot.intake_digest - || file.file.disposition != SnapshotFileDispositionV1::Present - || content_digest(&file.sanitized_bytes) != file.file.content_digest - || std::str::from_utf8(&file.sanitized_bytes).is_err() - { - return Err(IntakeRejectionV1::UnsanitizedInput); - } - let admitted_file = capability - .files_by_occurrence - .get(&file.file.file_occurrence_id) - .and_then(|index| capability.snapshot.snapshot.files.get(*index)); - if admitted_file != Some(&file.file) { - return Err(IntakeRejectionV1::UnsanitizedInput); - } - let snapshot = &capability.snapshot.snapshot; - let authority = ReceiptBoundCodeFileAuthorityV1 { - project_id: project_id.clone(), - repository_id: snapshot.repository.clone(), - worktree_id: snapshot.worktree.clone(), - reference: snapshot.reference.clone(), - logical_path: file.file.logical_path.clone(), - content_digest: file.file.content_digest.clone(), - }; - Ok(ReceiptBoundCodeFileV1 { file, authority }) + crate::hotpath_observe::measure_hot_loop!("code_index.intake.bind_file", { + if project_id.validate().is_err() { + return Err(IntakeRejectionV1::UnsanitizedInput); + } + if file.snapshot_digest != capability.snapshot.intake_digest + || file.file.disposition != SnapshotFileDispositionV1::Present + || content_digest(&file.sanitized_bytes) != file.file.content_digest + || std::str::from_utf8(&file.sanitized_bytes).is_err() + { + return Err(IntakeRejectionV1::UnsanitizedInput); + } + let admitted_file = capability + .files_by_occurrence + .get(&file.file.file_occurrence_id) + .and_then(|index| capability.snapshot.snapshot.files.get(*index)); + if admitted_file != Some(&file.file) { + return Err(IntakeRejectionV1::UnsanitizedInput); + } + let snapshot = &capability.snapshot.snapshot; + let authority = ReceiptBoundCodeFileAuthorityV1 { + project_id: project_id.clone(), + repository_id: snapshot.repository.clone(), + worktree_id: snapshot.worktree.clone(), + reference: snapshot.reference.clone(), + logical_path: file.file.logical_path.clone(), + content_digest: file.file.content_digest.clone(), + }; + Ok(ReceiptBoundCodeFileV1 { file, authority }) + }) } } diff --git a/crates/tracedecay-code-index/src/production/generation_statistics.rs b/crates/tracedecay-code-index/src/production/generation_statistics.rs index e6d3c7ff16..bc86ca2027 100644 --- a/crates/tracedecay-code-index/src/production/generation_statistics.rs +++ b/crates/tracedecay-code-index/src/production/generation_statistics.rs @@ -24,6 +24,7 @@ impl CodeIndexPublishedGenerationV1 { /// including parsed, error, and unsupported spans. Keeping the checked /// accumulation here makes a census faithful to the sealed generation and /// prevents downstream runtime telemetry from reading removed SQL tables. + #[hotpath::measure(label = "code_index.build.statistics")] pub fn generation_statistics( &self, ) -> Result { diff --git a/crates/tracedecay-code-index/src/production/helpers.rs b/crates/tracedecay-code-index/src/production/helpers.rs index 1b8bfbe49b..4bbe33874d 100644 --- a/crates/tracedecay-code-index/src/production/helpers.rs +++ b/crates/tracedecay-code-index/src/production/helpers.rs @@ -1,7 +1,7 @@ use super::*; pub(crate) struct StagedGenerationV1 { - pub(crate) files: Vec, + pub(crate) files: Vec>, pub(crate) chunks: GenerationChunkManifestV1, pub(crate) symbols: GenerationSymbolIndexV1, pub(crate) lineage: Vec, @@ -9,7 +9,7 @@ pub(crate) struct StagedGenerationV1 { pub(crate) fn staged_generation( generation_id: CodeGenerationId, - mut files: Vec, + mut files: Vec>, lineage: Vec, ) -> Result { files.sort_by(|left, right| { @@ -19,20 +19,26 @@ pub(crate) fn staged_generation( .file_occurrence_id .cmp(&right.artifacts.chunks.document.file_occurrence_id) }); - let chunks = GenerationChunkManifestV1::new( - generation_id.clone(), - files - .iter() - .map(|file| file.artifacts.chunks.clone()) - .collect(), + let chunks = hotpath::measure_block!( + "code_index.generation.aggregate_chunks", + GenerationChunkManifestV1::new( + generation_id.clone(), + files + .iter() + .map(|file| file.artifacts.chunks.clone()) + .collect(), + ) ) .map_err(CodeIndexProductionErrorV1::Increment)?; - let symbols = GenerationSymbolIndexV1::new( - generation_id, - files - .iter() - .flat_map(|file| file.artifacts.symbols.clone()) - .collect(), + let symbols = hotpath::measure_block!( + "code_index.generation.aggregate_symbols", + GenerationSymbolIndexV1::new( + generation_id, + files + .iter() + .flat_map(|file| file.artifacts.symbols.clone()) + .collect(), + ) ) .map_err(CodeIndexProductionErrorV1::Lineage)?; Ok(StagedGenerationV1 { @@ -114,7 +120,7 @@ pub(crate) fn captured_files( pub(crate) fn coverage_summary( snapshot: &SanitizedCodeSnapshotV1, - files: &[FileGenerationArtifactsV1], + files: &[Arc], ) -> CoverageSummaryV1 { let mut coverage = CoverageSummaryV1::default(); for file in &snapshot.files { @@ -196,17 +202,20 @@ pub(crate) fn edge_order( )) } -pub(crate) fn collect_edge_evidence( - files: &[FileGenerationArtifactsV1], -) -> (Vec, Vec) { +pub(crate) fn collect_edge_evidence( + files: &[T], +) -> (Vec, Vec) +where + T: AsRef, +{ let mut edges = files .iter() - .flat_map(|file| file.artifacts.edges.clone()) + .flat_map(|file| file.as_ref().artifacts.edges.clone()) .collect::>(); edges.sort_by(edge_order); let mut abstentions = files .iter() - .flat_map(|file| file.artifacts.edge_abstentions.clone()) + .flat_map(|file| file.as_ref().artifacts.edge_abstentions.clone()) .collect::>(); abstentions.sort(); (edges, abstentions) diff --git a/crates/tracedecay-code-index/src/production/import_evidence.rs b/crates/tracedecay-code-index/src/production/import_evidence.rs index d1f137e104..85db7fbf75 100644 --- a/crates/tracedecay-code-index/src/production/import_evidence.rs +++ b/crates/tracedecay-code-index/src/production/import_evidence.rs @@ -2,10 +2,11 @@ use crate::chunks::CodeIndexImportEvidenceV1; use super::{CodeIndexProductionErrorV1, FileGenerationArtifactsV1}; -pub(super) fn derive_import_evidence( - files: &[FileGenerationArtifactsV1], -) -> Vec { - derive_import_evidence_from(files.iter()) +pub(super) fn derive_import_evidence(files: &[T]) -> Vec +where + T: AsRef, +{ + derive_import_evidence_from(files.iter().map(AsRef::as_ref)) } fn derive_import_evidence_from<'a>( diff --git a/crates/tracedecay-code-index/src/production/lexical_page_source.rs b/crates/tracedecay-code-index/src/production/lexical_page_source.rs index 1d9b121a77..6af343e72c 100644 --- a/crates/tracedecay-code-index/src/production/lexical_page_source.rs +++ b/crates/tracedecay-code-index/src/production/lexical_page_source.rs @@ -1,8 +1,14 @@ -use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +use std::{ + collections::BTreeMap, + io::{BufRead, BufReader, Read, Seek, SeekFrom}, + num::NonZeroUsize, +}; use sha2::{Digest, Sha256}; use tracedecay_domain::ExactTechnicalTermV1; +use crate::{capabilities::expected_seal_digest, intake::INTAKE_DIGEST_SEPARATOR}; + use super::sealed_codec::{ LEGACY_CANONICAL_SEALED_GENERATION_FORMAT_REVISION, PersistedFileGenerationArtifactsV1, }; @@ -13,9 +19,14 @@ const SOURCE_DIGEST_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-source.v1\0"; const IMPORT_DICTIONARY_DIGEST_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-import-dictionary.v1\0"; const IMPORT_RECORD_DOMAIN: &[u8] = b"import\0"; const SOURCE_CHAIN_RECORD_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-source-chain.v1\0"; +const SYMBOL_DISPLAY_RECORD_DOMAIN: &[u8] = b"symbol-display\0"; +const SOURCE_SYMBOL_DISPLAY_CHAIN_RECORD_DOMAIN: &[u8] = + b"tracedecay.sealed-lexical-symbol-display-chain.v1\0"; const IMPORT_DICTIONARY_CHAIN_RECORD_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-import-dictionary-chain.v1\0"; const CURSOR_DIGEST_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-cursor.v1\0"; +const LAYOUT_PROGRESS_INTERVAL_BYTES: u64 = 16 * 1024 * 1024; +const MAX_LEXICAL_GENERATION_METADATA_BYTES: u64 = 64 * 1024 * 1024; type PersistedSealedLexicalCursorFields = ( String, @@ -224,6 +235,42 @@ impl VerifiedSealedLexicalCursorV1 { } } +/// Compact parser-attested display identity for one symbol-backed chunk. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct VerifiedSealedLexicalSymbolDisplayV1 { + occurrence: SymbolOccurrenceId, + simple_name: String, + qualified_name: String, + kind: String, +} + +impl VerifiedSealedLexicalSymbolDisplayV1 { + pub fn occurrence(&self) -> &SymbolOccurrenceId { + &self.occurrence + } + + pub fn simple_name(&self) -> &str { + &self.simple_name + } + + pub fn qualified_name(&self) -> &str { + &self.qualified_name + } + + pub fn kind(&self) -> &str { + &self.kind + } + + pub fn retained_owned_bytes(&self) -> usize { + self.occurrence + .as_str() + .len() + .saturating_add(self.simple_name.capacity()) + .saturating_add(self.qualified_name.capacity()) + .saturating_add(self.kind.capacity()) + } +} + /// One bounded page of parser-backed, sanitized search chunks. #[derive(Debug)] pub struct VerifiedSealedLexicalPageV1 { @@ -236,6 +283,7 @@ pub struct VerifiedSealedLexicalPageV1 { cumulative_digest: ManifestDigest, next_cursor: VerifiedSealedLexicalCursorV1, chunks: Vec, + symbol_displays: Vec>, imports: Vec, previous_cursor: VerifiedSealedLexicalCursorV1, } @@ -281,6 +329,14 @@ impl VerifiedSealedLexicalPageV1 { self.chunks.capacity() } + pub fn symbol_displays(&self) -> &[Option] { + &self.symbol_displays + } + + pub fn symbol_display_capacity(&self) -> usize { + self.symbol_displays.capacity() + } + pub fn imports(&self) -> &[CodeIndexImportEvidenceV1] { &self.imports } @@ -317,7 +373,12 @@ impl VerifiedSealedLexicalPageV1 { let mut cumulative_digest = self.previous_cursor.cumulative_digest.clone(); let mut import_dictionary_digest = self.previous_cursor.import_dictionary_digest.clone(); let mut payload_bytes = 0u64; - for admitted in &self.chunks { + if self.symbol_displays.len() != self.chunks.len() { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical symbol-display cardinality does not match its chunks".to_owned(), + )); + } + for (admitted, display) in self.chunks.iter().zip(&self.symbol_displays) { let serialized = serde_json::to_vec(admitted.chunk()).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "sealed lexical chunk serialization failed: {error}" @@ -326,6 +387,27 @@ impl VerifiedSealedLexicalPageV1 { hash_record(&mut page_hasher, &serialized)?; cumulative_digest = advance_digest(&cumulative_digest, SOURCE_CHAIN_RECORD_DOMAIN, &serialized)?; + match (&admitted.chunk().anchor.symbol_occurrence_id, display) { + (Some(occurrence), Some(display)) if occurrence == display.occurrence() => { + let serialized_display = serde_json::to_vec(display).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed lexical symbol display serialization failed: {error}" + )) + })?; + hash_symbol_display_record(&mut page_hasher, &serialized_display)?; + cumulative_digest = advance_digest( + &cumulative_digest, + SOURCE_SYMBOL_DISPLAY_CHAIN_RECORD_DOMAIN, + &serialized_display, + )?; + } + (None, None) => {} + _ => { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical symbol display does not match its chunk anchor".to_owned(), + )); + } + } payload_bytes = payload_bytes .checked_add(u64::try_from(serialized.len()).map_err(|_| { CodeIndexProductionErrorV1::Contract( @@ -527,12 +609,28 @@ impl VerifiedSealedLexicalPageV1 { .saturating_add(chunk.sanitized_text.as_str().len()) }, ); + let symbol_display_bytes = self.symbol_displays.iter().fold( + self.symbol_displays + .capacity() + .saturating_mul(std::mem::size_of::< + Option, + >()), + |bytes, display| { + bytes.saturating_add(display.as_ref().map_or( + 0, + VerifiedSealedLexicalSymbolDisplayV1::retained_owned_bytes, + )) + }, + ); self.imports.iter().fold( - chunk_bytes.saturating_add(digest_bytes).saturating_add( - self.imports - .capacity() - .saturating_mul(std::mem::size_of::()), - ), + chunk_bytes + .saturating_add(symbol_display_bytes) + .saturating_add(digest_bytes) + .saturating_add( + self.imports + .capacity() + .saturating_mul(std::mem::size_of::()), + ), |bytes, evidence| { bytes .saturating_add(evidence.logical_path.capacity()) @@ -639,9 +737,80 @@ pub enum VerifiedSealedLexicalPageReadV1 { Complete(VerifiedSealedLexicalSourceReceiptV1), } +/// Deterministic admission limits for one staged lexical-page batch. +/// +/// Both limits are required so a caller cannot accidentally turn the verified +/// source into an unbounded retained-page queue. `maximum_retained_bytes` +/// charges the batch vector slots and +/// [`VerifiedSealedLexicalPageV1::retained_owned_bytes`] for every page +/// staged in the batch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VerifiedSealedLexicalPageBatchBoundsV1 { + maximum_pages: usize, + maximum_retained_bytes: usize, + page_slot_bytes: usize, +} + +impl VerifiedSealedLexicalPageBatchBoundsV1 { + pub fn new( + maximum_pages: usize, + maximum_retained_bytes: usize, + ) -> Result { + if maximum_pages == 0 { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch count bound must be non-zero".to_owned(), + )); + } + if maximum_retained_bytes == 0 { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch retained-byte bound must be non-zero".to_owned(), + )); + } + let page_slot_bytes = maximum_pages + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch slot bound overflowed".to_owned(), + ) + })?; + if page_slot_bytes > maximum_retained_bytes { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch retained-byte bound cannot hold its page slots" + .to_owned(), + )); + } + Ok(Self { + maximum_pages, + maximum_retained_bytes, + page_slot_bytes, + }) + } + + pub fn maximum_pages(&self) -> usize { + self.maximum_pages + } + + pub fn maximum_retained_bytes(&self) -> usize { + self.maximum_retained_bytes + } + + fn page_slot_bytes(&self) -> usize { + self.page_slot_bytes + } +} + +/// Result of one accepted lexical-page batch prefix. +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] // staged pages dominate this terminal read result +pub enum VerifiedSealedLexicalPageBatchReadV1 { + Pages(Vec), + Complete(VerifiedSealedLexicalSourceReceiptV1), +} + struct PendingSealedLexicalPageV1 { chunks: Vec, page_bytes: usize, + symbol_displays: Vec>, imports: Vec, import_bytes: usize, cursor: VerifiedSealedLexicalCursorV1, @@ -659,6 +828,12 @@ enum StagedSealedLexicalPageReadV1 { Complete(VerifiedSealedLexicalSourceReceiptV1), } +#[allow(clippy::large_enum_variant)] // staged pages dominate this private batch result +enum StagedSealedLexicalPageBatchReadV1 { + Pages(Vec), + Complete(VerifiedSealedLexicalSourceReceiptV1), +} + /// Seekable, bounded lexical projection source over a verified v5/v6 seal. /// /// Opening performs a streaming structural scan and verifies the exact raw @@ -672,15 +847,46 @@ pub struct VerifiedSealedLexicalPageSourceV1 { file_count: u64, first_file_offset: u64, files_end_offset: u64, + total_lexical_bytes: u64, maximum_file_bytes: u64, source_state_digest: ManifestDigest, format_revision: u32, + metadata: VerifiedSealedTextGenerationMetadataV1, maximum_page_chunks: usize, maximum_page_bytes: usize, cursor: VerifiedSealedLexicalCursorV1, admitted_file: Option<(u64, AdmittedSealedLexicalFileV1)>, } +/// Authenticated generation metadata needed by exact and lexical serving. +/// +/// The full sealed generation can be gigabytes. This projection retains only +/// the manifest and sanitized snapshot header that precede the files array; +/// the layout scan authenticates the complete content-addressed seal before +/// this value becomes observable. +#[derive(Clone, Debug)] +pub struct VerifiedSealedTextGenerationMetadataV1 { + manifest: CodeGenerationManifestV1, + snapshot: SanitizedCodeSnapshotV1, +} + +impl VerifiedSealedTextGenerationMetadataV1 { + pub fn from_published_generation(generation: &CodeIndexPublishedGenerationV1) -> Self { + Self { + manifest: generation.manifest().clone(), + snapshot: generation.snapshot().clone(), + } + } + + pub fn manifest(&self) -> &CodeGenerationManifestV1 { + &self.manifest + } + + pub fn snapshot(&self) -> &SanitizedCodeSnapshotV1 { + &self.snapshot + } +} + impl VerifiedSealedLexicalPageSourceV1 { #[hotpath::measure] pub fn open( @@ -706,14 +912,25 @@ impl VerifiedSealedLexicalPageSourceV1 { layout.state_digest.clone(), layout.first_file_offset, )?; + let total_lexical_bytes = layout + .files_end_offset + .checked_sub(layout.first_file_offset) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical files array has an invalid byte span".to_owned(), + ) + })?; + let metadata = read_verified_text_metadata(&mut reader, &layout, control)?; Ok(Self { reader, file_count: layout.file_count, first_file_offset: layout.first_file_offset, files_end_offset: layout.files_end_offset, + total_lexical_bytes, maximum_file_bytes: layout.maximum_file_bytes, source_state_digest: layout.state_digest, format_revision: layout.format_revision, + metadata, maximum_page_chunks, maximum_page_bytes, cursor, @@ -730,7 +947,7 @@ impl VerifiedSealedLexicalPageSourceV1 { /// no whole-generation `Vec` is required merely to authenticate it. #[hotpath::measure] pub fn open_content_addressed( - mut reader: R, + reader: R, admitted_len: u64, expected_file_digest: ManifestDigest, maximum_page_chunks: usize, @@ -742,24 +959,68 @@ impl VerifiedSealedLexicalPageSourceV1 { "sealed lexical page bounds must be non-zero".to_owned(), )); } - let layout = scan_layout( + Self::open_content_addressed_with_progress( + reader, + admitted_len, + expected_file_digest, + maximum_page_chunks, + maximum_page_bytes, + control, + |_, _| {}, + ) + } + + /// Open a content-addressed source while reporting authenticated scan + /// bytes. The callback is invoked at zero, bounded byte intervals, and + /// exactly once with the admitted total before metadata is exposed. + #[hotpath::measure] + pub fn open_content_addressed_with_progress( + mut reader: R, + admitted_len: u64, + expected_file_digest: ManifestDigest, + maximum_page_chunks: usize, + maximum_page_bytes: usize, + control: &dyn CodeIndexExecutionControlV1, + mut progress: F, + ) -> Result + where + F: FnMut(u64, u64), + { + if maximum_page_chunks == 0 || maximum_page_bytes == 0 { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page bounds must be non-zero".to_owned(), + )); + } + let layout = scan_layout_with_progress( &mut reader, admitted_len, Some(&expected_file_digest), control, + &mut progress, )?; let cursor = VerifiedSealedLexicalCursorV1::initial( layout.state_digest.clone(), layout.first_file_offset, )?; + let total_lexical_bytes = layout + .files_end_offset + .checked_sub(layout.first_file_offset) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical files array has an invalid byte span".to_owned(), + ) + })?; + let metadata = read_verified_text_metadata(&mut reader, &layout, control)?; Ok(Self { reader, file_count: layout.file_count, first_file_offset: layout.first_file_offset, files_end_offset: layout.files_end_offset, + total_lexical_bytes, maximum_file_bytes: layout.maximum_file_bytes, source_state_digest: layout.state_digest, format_revision: layout.format_revision, + metadata, maximum_page_chunks, maximum_page_bytes, cursor, @@ -767,6 +1028,10 @@ impl VerifiedSealedLexicalPageSourceV1 { }) } + pub fn metadata(&self) -> &VerifiedSealedTextGenerationMetadataV1 { + &self.metadata + } + /// Reopen an authenticated durable source at an accepted persisted cursor. /// /// The layout scan authenticates the raw content address but does not @@ -864,6 +1129,35 @@ impl VerifiedSealedLexicalPageSourceV1 { &self.cursor } + /// Number of authenticated file records in this sealed lexical source. + pub fn total_files(&self) -> u64 { + self.file_count + } + + /// Authenticated files-array byte span available to the lexical source. + pub fn total_lexical_bytes(&self) -> u64 { + self.total_lexical_bytes + } + + /// Fully completed file records at the durable source cursor. + pub fn completed_files(&self) -> u64 { + self.cursor.next_file_ordinal() + } + + /// Authenticated files-array bytes fully passed by the durable source + /// cursor. A partially consumed file counts only after its final chunk and + /// imports are committed, matching `completed_files`. + pub fn completed_lexical_bytes(&self) -> Result { + self.cursor + .next_file_offset + .checked_sub(self.first_file_offset) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical cursor precedes the files-array start".to_owned(), + ) + }) + } + /// Restore this source to its just-opened state so a consumer whose /// staging failed after pages were already accepted can replay the same /// sealed pages on the same instance instead of terminally blocking. @@ -918,7 +1212,8 @@ impl VerifiedSealedLexicalPageSourceV1 { control: &dyn CodeIndexExecutionControlV1, admit: impl FnOnce(&VerifiedSealedLexicalPageV1) -> Result<(), E>, ) -> Result, CodeIndexProductionErrorV1> { - match self.stage_next_page(control)? { + let cursor = self.cursor.clone(); + match self.stage_next_page_at(&cursor, control)? { StagedSealedLexicalPageReadV1::Page(staged) => { if let Err(error) = admit(&staged.page) { return Ok(Err(error)); @@ -933,15 +1228,124 @@ impl VerifiedSealedLexicalPageSourceV1 { } } - fn stage_next_page( + /// Stage a bounded ordered page batch and advance only through the prefix + /// the caller durably accepts. The source cursor remains at its pre-batch + /// position on source, callback, or accepted-prefix validation failure, so + /// retrying emits the same ordered page sequence. + pub fn next_page_batch_if( + &mut self, + control: &dyn CodeIndexExecutionControlV1, + bounds: VerifiedSealedLexicalPageBatchBoundsV1, + admit: impl FnOnce(&[VerifiedSealedLexicalPageV1]) -> Result, + ) -> Result, CodeIndexProductionErrorV1> { + let staged = hotpath::measure_block!("code_index.lexical_source.batch_stage", { + (|| { + let mut pages = Vec::new(); + pages + .try_reserve_exact(bounds.maximum_pages()) + .map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed lexical page batch reservation failed: {error}" + )) + })?; + let retained_page_slots = pages + .capacity() + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch reservation overflowed".to_owned(), + ) + })?; + if retained_page_slots > bounds.maximum_retained_bytes() + || retained_page_slots < bounds.page_slot_bytes() + { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch reservation exceeds its retained-byte bound" + .to_owned(), + )); + } + + let mut working_cursor = self.cursor.clone(); + let mut retained_bytes = retained_page_slots; + let mut completion = None; + while pages.len() < bounds.maximum_pages() { + match self.stage_next_page_at(&working_cursor, control)? { + StagedSealedLexicalPageReadV1::Page(staged) => { + let next_retained_bytes = retained_bytes + .checked_add(staged.page.retained_owned_bytes()) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch retained bytes overflowed" + .to_owned(), + ) + })?; + if next_retained_bytes > bounds.maximum_retained_bytes() { + if pages.is_empty() { + return Err(CodeIndexProductionErrorV1::Contract( + "one sealed lexical page exceeds the batch retained-byte bound" + .to_owned(), + )); + } + break; + } + retained_bytes = next_retained_bytes; + working_cursor = staged.cursor; + pages.push(staged.page); + } + StagedSealedLexicalPageReadV1::Complete(receipt) => { + if pages.is_empty() { + completion = Some(receipt); + } + break; + } + } + } + + Ok(if let Some(receipt) = completion { + StagedSealedLexicalPageBatchReadV1::Complete(receipt) + } else { + StagedSealedLexicalPageBatchReadV1::Pages(pages) + }) + })() + })?; + + match staged { + StagedSealedLexicalPageBatchReadV1::Complete(receipt) => { + Ok(Ok(VerifiedSealedLexicalPageBatchReadV1::Complete(receipt))) + } + StagedSealedLexicalPageBatchReadV1::Pages(mut pages) => { + let accepted_prefix = match admit(&pages) { + Ok(accepted_prefix) => accepted_prefix, + Err(error) => return Ok(Err(error)), + }; + let accepted_page_count = accepted_prefix.get(); + if accepted_page_count > pages.len() { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch accepted prefix exceeds staged page count" + .to_owned(), + )); + } + let accepted_cursor = pages[accepted_page_count - 1].next_cursor().clone(); + pages.truncate(accepted_page_count); + crate::hotpath_observe::record_pages(accepted_cursor.next_page_ordinal()); + self.cursor = accepted_cursor; + Ok(Ok(VerifiedSealedLexicalPageBatchReadV1::Pages(pages))) + } + } + } + + fn stage_next_page_at( &mut self, + previous_cursor: &VerifiedSealedLexicalCursorV1, control: &dyn CodeIndexExecutionControlV1, ) -> Result { checkpoint(control)?; - let mut cursor = self.cursor.clone(); + let mut cursor = previous_cursor.clone(); let mut page_hasher = page_hasher(cursor.next_page_ordinal); let mut chunks = Vec::new(); let mut page_bytes = 0usize; + let mut symbol_displays = Vec::new(); + let mut symbol_display_bytes = 0usize; let mut imports = Vec::new(); let mut import_bytes = 0usize; @@ -969,13 +1373,43 @@ impl VerifiedSealedLexicalPageSourceV1 { } while chunk_ordinal < admitted.chunks.len() { checkpoint(control)?; - let serialized = serde_json::to_vec(admitted.chunks[chunk_ordinal].chunk()) + let chunk = admitted.chunks[chunk_ordinal].chunk(); + let display = match chunk.anchor.symbol_occurrence_id.as_ref() { + Some(occurrence) => Some( + admitted + .symbol_displays + .get(occurrence) + .cloned() + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical symbol chunk has no parser-attested display identity" + .to_owned(), + ) + })?, + ), + None => None, + }; + let serialized_display = display + .as_ref() + .map(serde_json::to_vec) + .transpose() .map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( - "sealed lexical chunk serialization failed: {error}" + "sealed lexical symbol display serialization failed: {error}" )) })?; - if serialized.len() > self.maximum_page_bytes { + let serialized = serde_json::to_vec(chunk).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed lexical chunk serialization failed: {error}" + )) + })?; + let next_symbol_display_bytes = symbol_display_bytes + .saturating_add(serialized_display.as_ref().map_or(0, Vec::len)); + if serialized + .len() + .saturating_add(serialized_display.as_ref().map_or(0, Vec::len)) + > self.maximum_page_bytes + { return Err(CodeIndexProductionErrorV1::Contract( "one admitted lexical chunk exceeds the page byte bound".to_owned(), )); @@ -983,18 +1417,23 @@ impl VerifiedSealedLexicalPageSourceV1 { if (!chunks.is_empty() || !imports.is_empty()) && (chunks.len() == self.maximum_page_chunks || page_bytes + .saturating_add(next_symbol_display_bytes) .saturating_add(import_bytes) .saturating_add(serialized.len()) > self.maximum_page_bytes) { - return self.commit_page(PendingSealedLexicalPageV1 { - chunks, - page_bytes, - imports, - import_bytes, - cursor, - page_hasher, - }); + return self.commit_page( + previous_cursor, + PendingSealedLexicalPageV1 { + chunks, + page_bytes, + symbol_displays, + imports, + import_bytes, + cursor, + page_hasher, + }, + ); } hash_record(&mut page_hasher, &serialized)?; cursor.cumulative_digest = advance_digest( @@ -1002,12 +1441,22 @@ impl VerifiedSealedLexicalPageSourceV1 { SOURCE_CHAIN_RECORD_DOMAIN, &serialized, )?; + if let Some(serialized_display) = serialized_display.as_deref() { + hash_symbol_display_record(&mut page_hasher, serialized_display)?; + cursor.cumulative_digest = advance_digest( + &cursor.cumulative_digest, + SOURCE_SYMBOL_DISPLAY_CHAIN_RECORD_DOMAIN, + serialized_display, + )?; + } page_bytes = page_bytes.checked_add(serialized.len()).ok_or_else(|| { CodeIndexProductionErrorV1::Contract( "sealed lexical page byte count overflowed".to_owned(), ) })?; + symbol_display_bytes = next_symbol_display_bytes; chunks.push(admitted.chunks[chunk_ordinal].clone()); + symbol_displays.push(display); chunk_ordinal += 1; cursor.next_chunk_ordinal = u64::try_from(chunk_ordinal).map_err(|_| { CodeIndexProductionErrorV1::Contract( @@ -1015,14 +1464,18 @@ impl VerifiedSealedLexicalPageSourceV1 { ) })?; if chunks.len() == self.maximum_page_chunks { - return self.commit_page(PendingSealedLexicalPageV1 { - chunks, - page_bytes, - imports, - import_bytes, - cursor, - page_hasher, - }); + return self.commit_page( + previous_cursor, + PendingSealedLexicalPageV1 { + chunks, + page_bytes, + symbol_displays, + imports, + import_bytes, + cursor, + page_hasher, + }, + ); } } let mut import_ordinal = usize::try_from(cursor.next_import_ordinal).map_err(|_| { @@ -1050,18 +1503,23 @@ impl VerifiedSealedLexicalPageSourceV1 { } if (!chunks.is_empty() || !imports.is_empty()) && page_bytes + .saturating_add(symbol_display_bytes) .saturating_add(import_bytes) .saturating_add(serialized.len()) > self.maximum_page_bytes { - return self.commit_page(PendingSealedLexicalPageV1 { - chunks, - page_bytes, - imports, - import_bytes, - cursor, - page_hasher, - }); + return self.commit_page( + previous_cursor, + PendingSealedLexicalPageV1 { + chunks, + page_bytes, + symbol_displays, + imports, + import_bytes, + cursor, + page_hasher, + }, + ); } hash_import_record(&mut page_hasher, &serialized)?; cursor.cumulative_digest = @@ -1099,38 +1557,46 @@ impl VerifiedSealedLexicalPageSourceV1 { // Commit after an importing file so a later file cannot append a // chunk after bytes already hashed as import records. if !imports.is_empty() { - return self.commit_page(PendingSealedLexicalPageV1 { + return self.commit_page( + previous_cursor, + PendingSealedLexicalPageV1 { + chunks, + page_bytes, + symbol_displays, + imports, + import_bytes, + cursor, + page_hasher, + }, + ); + } + } + + if !chunks.is_empty() || !imports.is_empty() { + return self.commit_page( + previous_cursor, + PendingSealedLexicalPageV1 { chunks, page_bytes, + symbol_displays, imports, import_bytes, cursor, page_hasher, - }); - } - } - - if !chunks.is_empty() || !imports.is_empty() { - return self.commit_page(PendingSealedLexicalPageV1 { - chunks, - page_bytes, - imports, - import_bytes, - cursor, - page_hasher, - }); + }, + ); } Ok(StagedSealedLexicalPageReadV1::Complete( VerifiedSealedLexicalSourceReceiptV1 { source_state_digest: self.source_state_digest.clone(), format_revision: self.format_revision, - page_count: self.cursor.next_page_ordinal, - total_chunks: self.cursor.emitted_chunks, - total_payload_bytes: self.cursor.emitted_payload_bytes, - total_imports: self.cursor.emitted_imports, - import_payload_bytes: self.cursor.emitted_import_payload_bytes, - import_dictionary_digest: self.cursor.import_dictionary_digest.clone(), - cumulative_digest: self.cursor.cumulative_digest.clone(), + page_count: previous_cursor.next_page_ordinal, + total_chunks: previous_cursor.emitted_chunks, + total_payload_bytes: previous_cursor.emitted_payload_bytes, + total_imports: previous_cursor.emitted_imports, + import_payload_bytes: previous_cursor.emitted_import_payload_bytes, + import_dictionary_digest: previous_cursor.import_dictionary_digest.clone(), + cumulative_digest: previous_cursor.cumulative_digest.clone(), }, )) } @@ -1177,12 +1643,30 @@ impl VerifiedSealedLexicalPageSourceV1 { } let exact_authority = ExactExtractionAuthorityV1::restore(&file.artifacts.chunks) .map_err(CodeIndexProductionErrorV1::Chunk)?; + let mut symbol_displays = BTreeMap::new(); + for symbol in &file.artifacts.symbols { + let display = VerifiedSealedLexicalSymbolDisplayV1 { + occurrence: symbol.occurrence.clone(), + simple_name: symbol.simple_name.clone(), + qualified_name: symbol.qualified_name.clone(), + kind: symbol.kind.clone(), + }; + if symbol_displays + .insert(symbol.occurrence.clone(), display) + .is_some() + { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical file contains duplicate symbol display identities".to_owned(), + )); + } + } let imports = file.artifacts.imports; let chunks = exact_authority .admit_all(file.artifacts.chunks.chunks) .map_err(CodeIndexProductionErrorV1::Chunk)?; Ok(AdmittedSealedLexicalFileV1 { chunks, + symbol_displays, imports, next_file_offset, }) @@ -1207,11 +1691,13 @@ impl VerifiedSealedLexicalPageSourceV1 { fn commit_page( &mut self, + previous_cursor: &VerifiedSealedLexicalCursorV1, pending: PendingSealedLexicalPageV1, ) -> Result { let PendingSealedLexicalPageV1 { chunks, page_bytes, + symbol_displays, imports, import_bytes, mut cursor, @@ -1287,8 +1773,9 @@ impl VerifiedSealedLexicalPageSourceV1 { cumulative_digest: cursor.cumulative_digest.clone(), next_cursor: cursor.clone(), chunks, + symbol_displays, imports, - previous_cursor: self.cursor.clone(), + previous_cursor: previous_cursor.clone(), }; Ok(StagedSealedLexicalPageReadV1::Page( StagedSealedLexicalPageV1 { page, cursor }, @@ -1299,6 +1786,7 @@ impl VerifiedSealedLexicalPageSourceV1 { #[derive(Debug)] struct AdmittedSealedLexicalFileV1 { chunks: Vec, + symbol_displays: BTreeMap, imports: Vec, next_file_offset: u64, } @@ -1310,13 +1798,36 @@ struct SealedLexicalLayoutV1 { first_file_offset: u64, files_end_offset: u64, maximum_file_bytes: u64, + manifest_range: Option<(u64, u64)>, + snapshot_range: Option<(u64, u64)>, + #[cfg(test)] + structural_byte_visits: u64, + #[cfg(test)] + temporary_string_allocations: u64, } +#[hotpath::measure] fn scan_layout( reader: &mut R, admitted_len: u64, expected_file_digest: Option<&ManifestDigest>, control: &dyn CodeIndexExecutionControlV1, +) -> Result { + scan_layout_with_progress( + reader, + admitted_len, + expected_file_digest, + control, + &mut |_, _| {}, + ) +} + +fn scan_layout_with_progress( + reader: &mut R, + admitted_len: u64, + expected_file_digest: Option<&ManifestDigest>, + control: &dyn CodeIndexExecutionControlV1, + progress: &mut dyn FnMut(u64, u64), ) -> Result { if admitted_len > MAX_SEALED_CODE_GENERATION_BYTES_V1 { return Err(CodeIndexProductionErrorV1::Contract( @@ -1326,6 +1837,10 @@ fn scan_layout( reader.seek(SeekFrom::Start(0)).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!("sealed lexical source seek failed: {error}")) })?; + hotpath::gauge!("code_index_lexical_layout_scan_attempts").inc(1); + hotpath::gauge!("code_index_lexical_layout_bytes_total").set(admitted_len); + hotpath::gauge!("code_index_lexical_layout_bytes_scanned").set(0); + progress(0, admitted_len); let mut scanner = LayoutScanner::default(); let mut file_hasher = expected_file_digest.map(|_| Sha256::new()); let read_limit = admitted_len.checked_add(1).ok_or_else(|| { @@ -1333,6 +1848,7 @@ fn scan_layout( })?; let mut remaining = read_limit; let mut observed = 0u64; + let mut next_progress = LAYOUT_PROGRESS_INTERVAL_BYTES; let mut buffer = [0u8; 64 * 1024]; while remaining > 0 { checkpoint(control)?; @@ -1373,6 +1889,12 @@ fn scan_layout( "sealed lexical source length overflowed".to_owned(), ) })?; + let admitted_observed = observed.min(admitted_len); + if admitted_observed >= next_progress || admitted_observed == admitted_len { + hotpath::gauge!("code_index_lexical_layout_bytes_scanned").set(admitted_observed); + progress(admitted_observed, admitted_len); + next_progress = admitted_observed.saturating_add(LAYOUT_PROGRESS_INTERVAL_BYTES); + } remaining -= read_bytes; } if observed != admitted_len { @@ -1390,16 +1912,40 @@ fn scan_layout( scanner.finish() } -#[derive(Default)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LayoutKey { + StateDigest, + Generation, + Files, + FormatRevision, + Manifest, + Snapshot, +} + +impl LayoutKey { + fn from_bytes(bytes: &[u8]) -> Option { + match bytes { + b"state_digest" => Some(Self::StateDigest), + b"generation" => Some(Self::Generation), + b"files" => Some(Self::Files), + b"format_revision" => Some(Self::FormatRevision), + b"manifest" => Some(Self::Manifest), + b"snapshot" => Some(Self::Snapshot), + _ => None, + } + } +} + struct LayoutScanner { brace_depth: usize, bracket_depth: usize, in_string: bool, escaped: bool, - string: Vec, + string: [u8; 128], + string_len: usize, string_overflowed: bool, - completed_string: Option, - pending_key: Option, + completed_key: Option, + pending_key: Option, capture_state_digest: bool, state_digest: Option, format_revision: Option, @@ -1412,6 +1958,48 @@ struct LayoutScanner { files_end_offset: Option, file_count: u64, maximum_file_bytes: u64, + captured_metadata_object: Option<(LayoutKey, u64, usize)>, + manifest_range: Option<(u64, u64)>, + snapshot_range: Option<(u64, u64)>, + #[cfg(test)] + structural_byte_visits: u64, + #[cfg(test)] + temporary_string_allocations: u64, +} + +impl Default for LayoutScanner { + fn default() -> Self { + Self { + brace_depth: 0, + bracket_depth: 0, + in_string: false, + escaped: false, + string: [0; 128], + string_len: 0, + string_overflowed: false, + completed_key: None, + pending_key: None, + capture_state_digest: false, + state_digest: None, + format_revision: None, + generation_depth: None, + generation_hasher: None, + generation_digest: None, + files_depth: None, + current_file_start: None, + first_file_offset: None, + files_end_offset: None, + file_count: 0, + maximum_file_bytes: 0, + captured_metadata_object: None, + manifest_range: None, + snapshot_range: None, + #[cfg(test)] + structural_byte_visits: 0, + #[cfg(test)] + temporary_string_allocations: 0, + } + } } /// Transition of the generation-payload hash span produced by one observed @@ -1434,7 +2022,19 @@ impl LayoutScanner { base_offset: u64, ) -> Result<(), CodeIndexProductionErrorV1> { let mut active_from = self.generation_hasher.is_some().then_some(0usize); - for (index, &byte) in bytes.iter().enumerate() { + let mut index = 0usize; + while index < bytes.len() { + if self.in_string && !self.escaped { + let relative_end = first_json_string_control(&bytes[index..]); + let end = relative_end.map_or(bytes.len(), |relative| index + relative); + if end > index { + self.observe_string_run(&bytes[index..end]); + index = end; + if index == bytes.len() { + break; + } + } + } let offset = u64::try_from(index) .ok() .and_then(|index| base_offset.checked_add(index)) @@ -1443,7 +2043,7 @@ impl LayoutScanner { "sealed lexical source length overflowed".to_owned(), ) })?; - match self.observe(byte, offset)? { + match self.observe(bytes[index], offset)? { GenerationSpanEvent::None => {} GenerationSpanEvent::Opened => active_from = Some(index), GenerationSpanEvent::Closed => { @@ -1461,6 +2061,7 @@ impl LayoutScanner { self.generation_digest = Some(digest_hasher(hasher)?); } } + index += 1; } if let Some(hasher) = self.generation_hasher.as_mut() { let start = active_from.ok_or_else(|| { @@ -1473,19 +2074,46 @@ impl LayoutScanner { Ok(()) } + /// Consume bytes that cannot alter JSON string state in one bounded step. + /// Only a key or the envelope state digest is retained, and both are + /// capped at the scanner's existing 128-byte contract. + fn observe_string_run(&mut self, bytes: &[u8]) { + #[cfg(test)] + { + self.structural_byte_visits = self.structural_byte_visits.saturating_add(1); + } + let remaining = self.string.len().saturating_sub(self.string_len); + let retained = remaining.min(bytes.len()); + let retained_end = self.string_len + retained; + self.string[self.string_len..retained_end].copy_from_slice(&bytes[..retained]); + self.string_len = retained_end; + if retained < bytes.len() { + self.string_overflowed = true; + } + } + + fn observe_string_byte(&mut self, byte: u8) { + if self.string_len < self.string.len() { + self.string[self.string_len] = byte; + self.string_len += 1; + } else { + self.string_overflowed = true; + } + } + fn observe( &mut self, byte: u8, offset: u64, ) -> Result { + #[cfg(test)] + { + self.structural_byte_visits = self.structural_byte_visits.saturating_add(1); + } if self.in_string { if self.escaped { self.escaped = false; - if self.string.len() < 128 { - self.string.push(byte); - } else { - self.string_overflowed = true; - } + self.observe_string_byte(byte); return Ok(GenerationSpanEvent::None); } match byte { @@ -1493,8 +2121,13 @@ impl LayoutScanner { b'"' => { self.in_string = false; if self.capture_state_digest { - let value = - String::from_utf8(std::mem::take(&mut self.string)).map_err(|_| { + #[cfg(test)] + { + self.temporary_string_allocations = + self.temporary_string_allocations.saturating_add(1); + } + let value = String::from_utf8(self.string[..self.string_len].to_vec()) + .map_err(|_| { CodeIndexProductionErrorV1::Contract( "sealed generation state digest is not UTF-8".to_owned(), ) @@ -1505,26 +2138,19 @@ impl LayoutScanner { self.capture_state_digest = false; self.pending_key = None; } else if !self.string_overflowed { - self.completed_string = Some( - String::from_utf8(std::mem::take(&mut self.string)).map_err(|_| { - CodeIndexProductionErrorV1::Contract( - "sealed generation key is not UTF-8".to_owned(), - ) - })?, - ); + std::str::from_utf8(&self.string[..self.string_len]).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "sealed generation key is not UTF-8".to_owned(), + ) + })?; + self.completed_key = LayoutKey::from_bytes(&self.string[..self.string_len]); } else { - self.string.clear(); - self.completed_string = None; + self.completed_key = None; } + self.string_len = 0; self.string_overflowed = false; } - _ => { - if self.string.len() < 128 { - self.string.push(byte); - } else { - self.string_overflowed = true; - } - } + _ => self.observe_string_byte(byte), } return Ok(GenerationSpanEvent::None); } @@ -1533,18 +2159,35 @@ impl LayoutScanner { match byte { b'"' => { self.in_string = true; - self.string.clear(); + self.string_len = 0; self.string_overflowed = false; self.capture_state_digest = - self.pending_key.as_deref() == Some("state_digest") && self.brace_depth == 1; + self.pending_key == Some(LayoutKey::StateDigest) && self.brace_depth == 1; } - b':' => self.pending_key = self.completed_string.take(), + b':' => self.pending_key = self.completed_key.take(), b'{' => { - if self.pending_key.as_deref() == Some("generation") && self.brace_depth == 1 { + if self.pending_key == Some(LayoutKey::Generation) && self.brace_depth == 1 { self.generation_depth = Some(self.brace_depth + 1); self.generation_hasher = Some(Sha256::new()); event = GenerationSpanEvent::Opened; } + if matches!( + self.pending_key, + Some(LayoutKey::Manifest | LayoutKey::Snapshot) + ) && self.generation_depth == Some(self.brace_depth) + { + let key = self.pending_key.ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed text metadata key disappeared".to_owned(), + ) + })?; + if self.captured_metadata_object.is_some() { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed text metadata objects overlap".to_owned(), + )); + } + self.captured_metadata_object = Some((key, offset, self.brace_depth + 1)); + } if self.files_depth == Some(self.bracket_depth) && self.generation_depth == Some(self.brace_depth) && self.current_file_start.is_none() @@ -1555,6 +2198,25 @@ impl LayoutScanner { self.pending_key = None; } b'}' => { + if let Some((key, start, depth)) = self.captured_metadata_object + && depth == self.brace_depth + { + let end = offset.checked_add(1).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed text metadata end offset overflowed".to_owned(), + ) + })?; + match key { + LayoutKey::Manifest => self.manifest_range = Some((start, end)), + LayoutKey::Snapshot => self.snapshot_range = Some((start, end)), + _ => { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed text metadata capture has an invalid key".to_owned(), + )); + } + } + self.captured_metadata_object = None; + } if let Some(start) = self.current_file_start && self .generation_depth @@ -1590,7 +2252,7 @@ impl LayoutScanner { self.pending_key = None; } b'[' => { - if self.pending_key.as_deref() == Some("files") + if self.pending_key == Some(LayoutKey::Files) && self.generation_depth == Some(self.brace_depth) { self.files_depth = Some(self.bracket_depth + 1); @@ -1611,18 +2273,18 @@ impl LayoutScanner { self.pending_key = None; } b'0'..=b'9' - if self.pending_key.as_deref() == Some("format_revision") + if self.pending_key == Some(LayoutKey::FormatRevision) && self.generation_depth == Some(self.brace_depth) => { self.format_revision = Some(u32::from(byte - b'0')); self.pending_key = None; } b',' => { - self.completed_string = None; + self.completed_key = None; self.pending_key = None; } byte if byte.is_ascii_whitespace() => {} - _ => self.completed_string = None, + _ => self.completed_key = None, } Ok(event) } @@ -1632,6 +2294,7 @@ impl LayoutScanner { || self.brace_depth != 0 || self.bracket_depth != 0 || self.current_file_start.is_some() + || self.captured_metadata_object.is_some() { return Err(CodeIndexProductionErrorV1::Contract( "sealed lexical source has incomplete JSON structure".to_owned(), @@ -1677,8 +2340,135 @@ impl LayoutScanner { first_file_offset, files_end_offset, maximum_file_bytes: self.maximum_file_bytes, + manifest_range: self.manifest_range, + snapshot_range: self.snapshot_range, + #[cfg(test)] + structural_byte_visits: self.structural_byte_visits, + #[cfg(test)] + temporary_string_allocations: self.temporary_string_allocations, + }) + } +} + +/// Locate the next quote or escape marker with eight-byte candidate probes. +/// Every input byte is still authenticated by the outer SHA-256 stream; this +/// helper only avoids interpreting ordinary string payload bytes one by one. +fn first_json_string_control(bytes: &[u8]) -> Option { + const LOW_BITS: u64 = 0x0101_0101_0101_0101; + const HIGH_BITS: u64 = 0x8080_8080_8080_8080; + const QUOTES: u64 = u64::from_ne_bytes([b'"'; 8]); + const ESCAPES: u64 = u64::from_ne_bytes([b'\\'; 8]); + + fn contains_zero_byte(value: u64) -> bool { + value.wrapping_sub(LOW_BITS) & !value & HIGH_BITS != 0 + } + + let mut chunks = bytes.chunks_exact(8); + for (chunk_index, chunk) in chunks.by_ref().enumerate() { + let word = u64::from_ne_bytes([ + chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], chunk[7], + ]); + if contains_zero_byte(word ^ QUOTES) || contains_zero_byte(word ^ ESCAPES) { + let base = chunk_index * 8; + return chunk + .iter() + .position(|byte| matches!(*byte, b'"' | b'\\')) + .map(|relative| base + relative); + } + } + let tail_base = bytes.len() - chunks.remainder().len(); + chunks + .remainder() + .iter() + .position(|byte| matches!(*byte, b'"' | b'\\')) + .map(|relative| tail_base + relative) +} + +fn read_verified_text_metadata( + reader: &mut R, + layout: &SealedLexicalLayoutV1, + control: &dyn CodeIndexExecutionControlV1, +) -> Result { + #[hotpath::measure] + fn decode_range( + reader: &mut R, + range: (u64, u64), + label: &'static str, + control: &dyn CodeIndexExecutionControlV1, + ) -> Result { + checkpoint(control)?; + let length = range.1.checked_sub(range.0).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata range is invalid" + )) + })?; + if length == 0 || length > MAX_LEXICAL_GENERATION_METADATA_BYTES { + return Err(CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata exceeds its byte bound" + ))); + } + let length = usize::try_from(length).map_err(|_| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata exceeds the platform limit" + )) + })?; + reader.seek(SeekFrom::Start(range.0)).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata seek failed: {error}" + )) + })?; + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata read failed: {error}" + )) + })?; + checkpoint(control)?; + serde_json::from_slice(&bytes).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata decoding failed: {error}" + )) }) } + + let manifest: CodeGenerationManifestV1 = decode_range( + reader, + layout.manifest_range.ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed generation manifest metadata is missing".to_owned(), + ) + })?, + "manifest", + control, + )?; + let snapshot: SanitizedCodeSnapshotV1 = decode_range( + reader, + layout.snapshot_range.ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed generation snapshot metadata is missing".to_owned(), + ) + })?, + "snapshot", + control, + )?; + snapshot + .validate() + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; + let snapshot_digest = canonical_sha256(&(INTAKE_DIGEST_SEPARATOR, &snapshot)) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; + if snapshot_digest != manifest.snapshot_digest { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed text metadata snapshot digest does not match the manifest".to_owned(), + )); + } + let seal_digest = expected_seal_digest(&manifest) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; + if seal_digest != manifest.seal.expected_digest { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed text metadata manifest seal is invalid".to_owned(), + )); + } + Ok(VerifiedSealedTextGenerationMetadataV1 { manifest, snapshot }) } fn read_next_file_bytes( @@ -1857,6 +2647,14 @@ fn hash_import_record(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), CodeIndex hash_record(hasher, bytes) } +fn hash_symbol_display_record( + hasher: &mut Sha256, + bytes: &[u8], +) -> Result<(), CodeIndexProductionErrorV1> { + hasher.update(SYMBOL_DISPLAY_RECORD_DOMAIN); + hash_record(hasher, bytes) +} + fn hash_record(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), CodeIndexProductionErrorV1> { let byte_len = u64::try_from(bytes.len()).map_err(|_| { CodeIndexProductionErrorV1::Contract("sealed lexical digest record exceeds u64".to_owned()) @@ -1870,3 +2668,893 @@ fn digest_hasher(hasher: Sha256) -> Result usize { 1 }\n", + "pub fn retained_batch_page() -> &'static str { ", + "\"retained-batch-page-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\" }\n", + "pub fn final_batch_page() -> usize { 3 }\n", + ); + + #[derive(Default)] + struct TestPublicationStore; + + impl CodeIndexAtomicPublicationPort for TestPublicationStore { + fn load_active( + &self, + _scope: &CodeIndexGenerationScopeV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> + { + Ok(None) + } + + fn publish_atomically( + &mut self, + _scope: &CodeIndexGenerationScopeV1, + _expected_active_generation: Option<&tracedecay_domain::CodeGenerationId>, + _generation: Arc, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + Ok(()) + } + } + + #[derive(Default)] + struct ApplyingProjectionSink; + + impl CodeChunkProjectionSink for ApplyingProjectionSink { + fn project_changed_chunks( + &mut self, + request: &tracedecay_domain::ProjectionBatchRequestV1, + receipt_builder: ProjectionReceiptBuilderV1<'_>, + ) -> Result { + let mut decisions: Vec = request + .changes + .added_or_changed + .iter() + .map(|change| ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: change.current_digest.clone(), + operation: if change.prior_digest.is_some() { + tracedecay_domain::ProjectionOperationV1::Updated + } else { + tracedecay_domain::ProjectionOperationV1::Added + }, + outcome: tracedecay_domain::ProjectionOutcomeV1::Applied, + output_digest: Some( + change + .current_digest + .clone() + .expect("added or changed chunks have a digest"), + ), + }) + .collect(); + decisions.extend(request.changes.deleted.iter().map(|change| { + ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: None, + operation: tracedecay_domain::ProjectionOperationV1::Deleted, + outcome: tracedecay_domain::ProjectionOutcomeV1::Applied, + output_digest: None, + } + })); + decisions.extend(request.changes.reused.iter().map(|change| { + ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: change.current_digest.clone(), + operation: tracedecay_domain::ProjectionOperationV1::Reused, + outcome: tracedecay_domain::ProjectionOutcomeV1::Reused, + output_digest: None, + } + })); + receipt_builder + .build(&decisions) + .map_err(|error| ProjectionSinkErrorV1::Rejected(error.to_string())) + } + } + + struct CancelDuringStaging { + checks: AtomicUsize, + } + + impl CancelDuringStaging { + fn new() -> Self { + Self { + checks: AtomicUsize::new(0), + } + } + } + + impl CodeIndexExecutionControlV1 for CancelDuringStaging { + fn is_cancelled(&self) -> bool { + self.checks.fetch_add(1, Ordering::AcqRel) >= 3 + } + + fn is_deadline_exceeded(&self) -> bool { + false + } + } + + struct ActiveControl; + + impl CodeIndexExecutionControlV1 for ActiveControl { + fn is_cancelled(&self) -> bool { + false + } + + fn is_deadline_exceeded(&self) -> bool { + false + } + } + + struct SealedSourceFixture { + sealed: Vec, + state_digest: ManifestDigest, + } + + impl SealedSourceFixture { + fn open(&self) -> VerifiedSealedLexicalPageSourceV1>> { + VerifiedSealedLexicalPageSourceV1::open( + Cursor::new(self.sealed.clone()), + u64::try_from(self.sealed.len()).expect("sealed fixture length fits u64"), + self.state_digest.clone(), + 1, + 1024 * 1024, + &ActiveControl, + ) + .expect("real sealed fixture source opens") + } + } + + #[derive(Debug, PartialEq, Eq)] + struct OnePageExpectation { + page_ordinal: u64, + chunk_count: u64, + payload_bytes: u64, + import_count: u64, + import_payload_bytes: u64, + page_digest: String, + next_cursor: Vec, + retained_owned_bytes: usize, + } + + fn fixture() -> SealedSourceFixture { + fixture_for_source(BATCH_FIXTURE_SOURCE) + } + + #[test] + fn content_addressed_open_reports_authenticated_scan_progress_and_text_metadata() { + let fixture = fixture(); + let file_digest = ManifestDigest::from_sha256_bytes(&Sha256::digest(&fixture.sealed)) + .expect("fixture file digest is canonical"); + let mut progress = Vec::new(); + let source = VerifiedSealedLexicalPageSourceV1::open_content_addressed_with_progress( + Cursor::new(fixture.sealed.clone()), + u64::try_from(fixture.sealed.len()).expect("fixture length fits u64"), + file_digest, + 1, + 1024 * 1024, + &ActiveControl, + |scanned, total| progress.push((scanned, total)), + ) + .expect("authenticated source opens with progress"); + + assert_eq!(progress.first(), Some(&(0, fixture.sealed.len() as u64))); + assert_eq!( + progress.last(), + Some(&(fixture.sealed.len() as u64, fixture.sealed.len() as u64)) + ); + assert_eq!( + source.metadata().snapshot().repository.as_str(), + "repository.lexical-page-batch" + ); + assert_eq!( + source.metadata().snapshot().files[0].logical_path, + "src/batch_fixture.rs" + ); + assert_eq!( + source.metadata().manifest().project_id.as_str(), + "project.lexical-page-batch" + ); + assert_eq!( + source.metadata().manifest().privacy_domain.as_str(), + "privacy.lexical-page-batch" + ); + } + + fn fixture_for_source(source: &str) -> SealedSourceFixture { + let source = source.as_bytes(); + let file = SanitizedCodeFileV1 { + file_occurrence_id: FileOccurrenceId::new("file.lexical-page-batch") + .expect("fixture file occurrence ID"), + logical_path: "src/batch_fixture.rs".to_owned(), + language: Some(LanguageId::new("rust").expect("fixture language ID")), + content_digest: content_digest(source), + disposition: SnapshotFileDispositionV1::Present, + }; + let snapshot = SanitizedCodeSnapshotV1 { + repository: RepositoryId::new("repository.lexical-page-batch") + .expect("fixture repository ID"), + worktree: None, + reference: None, + source_revision: None, + sanitizer_revision: SanitizerRevision::new("sanitizer.lexical-page-batch") + .expect("fixture sanitizer revision"), + sanitization_receipts: vec![ + SanitizationReceiptId::new("receipt.lexical-page-batch") + .expect("fixture sanitization receipt"), + ], + content_identity: content_digest(source), + captured_at: UtcMicros(1_000_000), + files: vec![file.clone()], + }; + let request = CodeIndexBuildRequestV1 { + snapshot, + captured_files: vec![CodeIndexCapturedFileV1 { + file_occurrence_id: file.file_occurrence_id, + sanitized_bytes: Arc::from(source), + sensitivity_level: SensitivityLevelV1::Public, + }], + changed_files: BTreeSet::new(), + invalidations: BTreeSet::new(), + ignored_source_admissions: Vec::new(), + repository_parse_identity: CodeIndexRepositoryParseIdentityV1 { + tree: None, + dirty: RepositoryDirtyStateV1::Dirty, + }, + sealed_at: UtcMicros(1_100_000), + target_projection_key: ProjectionKeyV1 { + kind: ProjectionKindV1::Lexical, + schema_revision: "lexical.v1".to_owned(), + profile_digest: ManifestDigest::new(format!("sha256:{}", "e".repeat(64))) + .expect("fixture projection profile digest"), + }, + }; + let mut owner = CodeIndexProductionOwnerV1::new( + CodeIndexProductionConfigV1 { + project_id: ProjectId::new("project.lexical-page-batch") + .expect("fixture project ID"), + repository: RepositoryId::new("repository.lexical-page-batch") + .expect("fixture repository ID"), + sanitizer_revision: SanitizerRevision::new("sanitizer.lexical-page-batch") + .expect("fixture sanitizer revision"), + policy_revision: PolicyRevisionId::new("policy.lexical-page-batch") + .expect("fixture policy revision"), + chunker_revision: ChunkerRevision::new("chunker.lexical-page-batch") + .expect("fixture chunker revision"), + privacy_domain: PrivacyDomainId::new("privacy.lexical-page-batch") + .expect("fixture privacy domain"), + privacy_key_epoch: 7, + max_snapshot_age_micros: None, + }, + TestPublicationStore, + ApplyingProjectionSink, + ) + .expect("fixture production owner opens"); + let generation = owner + .build_and_publish(request, &ActiveControl) + .expect("fixture generation publishes"); + let sealed = generation + .encode_sealed() + .expect("fixture generation seals"); + let envelope: serde_json::Value = + serde_json::from_slice(&sealed).expect("fixture sealed envelope decodes"); + let state_digest = ManifestDigest::new( + envelope["state_digest"] + .as_str() + .expect("fixture sealed state digest"), + ) + .expect("fixture state digest is canonical"); + SealedSourceFixture { + sealed, + state_digest, + } + } + + fn one_page_expectations(fixture: &SealedSourceFixture) -> Vec { + let mut source = fixture.open(); + let mut expectations = Vec::new(); + loop { + match source + .next_page(&ActiveControl) + .expect("fixture one-page read") + { + VerifiedSealedLexicalPageReadV1::Page(page) => { + expectations.push(expectation(&page)); + } + VerifiedSealedLexicalPageReadV1::Complete(receipt) => { + receipt + .verify_completion(Some(source.cursor())) + .expect("fixture one-page receipt verifies"); + return expectations; + } + } + } + } + + fn expectation(page: &VerifiedSealedLexicalPageV1) -> OnePageExpectation { + OnePageExpectation { + page_ordinal: page.page_ordinal(), + chunk_count: page.chunk_count(), + payload_bytes: page.payload_bytes(), + import_count: page.import_count(), + import_payload_bytes: page.import_payload_bytes(), + page_digest: page.page_digest().as_str().to_owned(), + next_cursor: page + .next_cursor() + .persisted_bytes() + .expect("one-page cursor persists"), + retained_owned_bytes: page.retained_owned_bytes(), + } + } + + fn assert_page_matches(page: &VerifiedSealedLexicalPageV1, expected: &OnePageExpectation) { + assert_eq!(page.page_ordinal(), expected.page_ordinal); + assert_eq!(page.chunk_count(), expected.chunk_count); + assert_eq!(page.payload_bytes(), expected.payload_bytes); + assert_eq!(page.import_count(), expected.import_count); + assert_eq!(page.import_payload_bytes(), expected.import_payload_bytes); + assert_eq!(page.page_digest().as_str(), expected.page_digest.as_str()); + assert_eq!( + page.next_cursor() + .persisted_bytes() + .expect("batch page cursor persists"), + expected.next_cursor, + ); + } + + fn bounds_for(expected: &[OnePageExpectation]) -> VerifiedSealedLexicalPageBatchBoundsV1 { + let page_slots = std::mem::size_of::() + .checked_mul(expected.len()) + .expect("fixture page-slot bytes do not overflow"); + let retained_bytes = expected.iter().fold(page_slots, |bytes, page| { + bytes + .checked_add(page.retained_owned_bytes) + .expect("fixture retained bytes do not overflow") + }); + VerifiedSealedLexicalPageBatchBoundsV1::new(expected.len(), retained_bytes) + .expect("fixture batch bounds are retainable") + } + + fn pages(read: VerifiedSealedLexicalPageBatchReadV1) -> Vec { + match read { + VerifiedSealedLexicalPageBatchReadV1::Pages(pages) => pages, + VerifiedSealedLexicalPageBatchReadV1::Complete(_) => { + panic!("fixture must stage lexical pages") + } + } + } + + #[test] + fn batch_bounds_refuse_limits_that_cannot_retain_a_bounded_page_batch() { + let page_slot_bytes = std::mem::size_of::(); + for (maximum_pages, maximum_retained_bytes) in + [(0, 1), (1, 0), (1, page_slot_bytes.saturating_sub(1))] + { + let error = + VerifiedSealedLexicalPageBatchBoundsV1::new(maximum_pages, maximum_retained_bytes) + .expect_err("an unbounded or unretainable batch must be refused"); + assert!(matches!(error, CodeIndexProductionErrorV1::Contract(_))); + } + } + + #[test] + fn rejected_batch_keeps_the_exact_cursor_and_retries_the_first_one_page_value() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + assert!( + expected.len() >= 2, + "fixture must provide a multi-page source" + ); + let mut source = fixture.open(); + let cursor_before = source + .cursor() + .persisted_bytes() + .expect("initial cursor persists"); + let rejected = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected[..2]), |pages| { + assert_eq!(pages.len(), 2, "fixture stages a full two-page batch"); + Err::("builder rejects the complete batch") + }) + .expect("source stages the rejected batch"); + assert_eq!( + rejected.expect_err("callback refusal must be surfaced"), + "builder rejects the complete batch" + ); + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("rejected cursor persists"), + cursor_before, + ); + + let retried = match source.next_page(&ActiveControl).expect("one-page retry") { + VerifiedSealedLexicalPageReadV1::Page(page) => page, + VerifiedSealedLexicalPageReadV1::Complete(_) => panic!("fixture must retain pages"), + }; + assert_page_matches(&retried, &expected[0]); + } + + #[test] + fn out_of_range_accepted_prefix_keeps_the_exact_cursor_and_retries_the_first_page() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + assert!( + expected.len() >= 2, + "fixture must provide a multi-page source" + ); + let mut source = fixture.open(); + let cursor_before = source + .cursor() + .persisted_bytes() + .expect("initial cursor persists"); + let error = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected[..2]), |pages| { + assert_eq!(pages.len(), 2, "fixture stages a full two-page batch"); + Ok::<_, ()>( + NonZeroUsize::new(pages.len() + 1) + .expect("out-of-range accepted prefix remains non-zero"), + ) + }) + .expect_err("out-of-range accepted prefix must be refused"); + assert!(matches!(error, CodeIndexProductionErrorV1::Contract(_))); + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("rejected cursor persists"), + cursor_before, + ); + + let retried = match source.next_page(&ActiveControl).expect("one-page retry") { + VerifiedSealedLexicalPageReadV1::Page(page) => page, + VerifiedSealedLexicalPageReadV1::Complete(_) => panic!("fixture must retain pages"), + }; + assert_page_matches(&retried, &expected[0]); + } + + #[test] + fn count_bound_returns_the_first_two_one_page_values_in_order() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + assert!( + expected.len() >= 2, + "fixture must provide a multi-page source" + ); + let mut source = fixture.open(); + let batch = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected[..2]), |pages| { + assert_eq!(pages.len(), 2); + Ok::<_, ()>(NonZeroUsize::new(pages.len()).expect("staged batch is non-empty")) + }) + .expect("source stages a count-bounded batch") + .expect("callback accepts the count-bounded batch"); + let batch = pages(batch); + assert_eq!(batch.len(), 2); + assert_page_matches(&batch[0], &expected[0]); + assert_page_matches(&batch[1], &expected[1]); + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("batch cursor persists"), + expected[1].next_cursor, + ); + } + + #[test] + fn accepts_only_fifteen_of_sixteen_staged_parser_backed_pages() { + let source_text = (0..16) + .map(|index| format!("pub fn batch_prefix_page_{index}() -> usize {{ {index} }}\n")) + .collect::(); + let fixture = fixture_for_source(&source_text); + let expected = one_page_expectations(&fixture); + assert!( + expected.len() >= 16, + "parser-backed fixture must expose sixteen one-page values" + ); + let mut source = fixture.open(); + let accepted = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected[..16]), |pages| { + assert_eq!( + pages.len(), + 16, + "fixture stages sixteen parser-backed pages" + ); + Ok::<_, ()>(NonZeroUsize::new(15).expect("fifteen is non-zero")) + }) + .expect("source stages the parser-backed batch") + .expect("callback accepts a fifteen-page prefix"); + let accepted = pages(accepted); + assert_eq!(accepted.len(), 15); + for (page, expected) in accepted.iter().zip(&expected[..15]) { + assert_page_matches(page, expected); + } + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("accepted-prefix cursor persists"), + expected[14].next_cursor, + ); + + let next = match source + .next_page(&ActiveControl) + .expect("read the first unaccepted page") + { + VerifiedSealedLexicalPageReadV1::Page(page) => page, + VerifiedSealedLexicalPageReadV1::Complete(_) => { + panic!("the sixteenth staged page must remain available") + } + }; + assert_page_matches(&next, &expected[15]); + } + + #[test] + fn retained_byte_bound_stops_before_the_next_larger_one_page_value() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + let (start, first, second) = expected + .windows(2) + .enumerate() + .find_map(|(index, pair)| { + (pair[0].retained_owned_bytes < pair[1].retained_owned_bytes) + .then_some((index, &pair[0], &pair[1])) + }) + .expect("fixture has an increasing one-page retained-byte boundary"); + let mut source = fixture.open(); + for _ in 0..start { + let _ = source + .next_page(&ActiveControl) + .expect("advance to retained boundary"); + } + let page_slots = std::mem::size_of::() + .checked_mul(2) + .expect("fixture page-slot bytes do not overflow"); + let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new( + 2, + page_slots + .checked_add(first.retained_owned_bytes) + .expect("fixture retained bound does not overflow"), + ) + .expect("first page fits the retained-byte bound"); + let batch = source + .next_page_batch_if(&ActiveControl, bounds, |pages| { + assert_eq!(pages.len(), 1, "larger next page must stay unstaged"); + Ok::<_, ()>(NonZeroUsize::new(pages.len()).expect("staged batch is non-empty")) + }) + .expect("source stages the retained-byte-bounded batch") + .expect("callback accepts the retained-byte-bounded batch"); + let batch = pages(batch); + assert_eq!(batch.len(), 1); + assert_page_matches(&batch[0], first); + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("retained-byte cursor persists"), + first.next_cursor, + ); + + let next = match source + .next_page(&ActiveControl) + .expect("read byte-stopped page") + { + VerifiedSealedLexicalPageReadV1::Page(page) => page, + VerifiedSealedLexicalPageReadV1::Complete(_) => panic!("fixture must retain next page"), + }; + assert_page_matches(&next, second); + } + + #[test] + fn completion_follows_the_last_accepted_batch_without_an_empty_callback() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + assert!(!expected.is_empty(), "fixture must provide lexical pages"); + let mut source = fixture.open(); + let accepted = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected), |pages| { + assert_eq!(pages.len(), expected.len()); + Ok::<_, ()>(NonZeroUsize::new(pages.len()).expect("staged batch is non-empty")) + }) + .expect("source stages the final batch") + .expect("callback accepts the final batch"); + let accepted = pages(accepted); + assert_eq!(accepted.len(), expected.len()); + for (page, expected) in accepted.iter().zip(&expected) { + assert_page_matches(page, expected); + } + + let mut callback_called = false; + let complete = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected), |_| { + callback_called = true; + Ok::<_, ()>(NonZeroUsize::MIN) + }) + .expect("completed source stays readable") + .expect("completion has no callback error"); + let VerifiedSealedLexicalPageBatchReadV1::Complete(receipt) = complete else { + panic!("completion follows the last accepted batch") + }; + assert!( + !callback_called, + "completion must not invoke an empty callback" + ); + receipt + .verify_completion(Some(source.cursor())) + .expect("completed receipt matches accepted cursor"); + } + + #[test] + fn cancellation_during_staging_keeps_the_exact_pre_batch_cursor() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + assert!( + expected.len() >= 2, + "fixture must provide a multi-page source" + ); + let mut source = fixture.open(); + let cursor_before = source + .cursor() + .persisted_bytes() + .expect("initial cursor persists"); + let control = CancelDuringStaging::new(); + let mut callback_called = false; + let error = source + .next_page_batch_if(&control, bounds_for(&expected[..2]), |_| { + callback_called = true; + Ok::<_, ()>(NonZeroUsize::MIN) + }) + .expect_err("cancellation must interrupt batch staging"); + assert!(matches!( + error, + CodeIndexProductionErrorV1::Interrupted(CodeIndexInterruptionV1::Cancelled) + )); + assert!( + control.checks.load(Ordering::Acquire) > 1, + "cancellation must be checked during source staging" + ); + assert!( + !callback_called, + "cancelled staging must not invoke the callback" + ); + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("cancelled cursor persists"), + cursor_before, + ); + } + + #[test] + fn large_string_layout_scan_skips_non_structural_bytes() { + const PAYLOAD_BYTES: usize = 8 * 1024 * 1024; + let file = format!(r#"{{"payload":"{}"}}"#, "x".repeat(PAYLOAD_BYTES)); + let generation = format!(r#"{{"format_revision":6,"files":[{file}]}}"#); + let state_digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(generation.as_bytes())) + .expect("synthetic generation digest is canonical"); + let sealed = format!( + r#"{{"state_digest":"{}","generation":{generation}}}"#, + state_digest.as_str() + ) + .into_bytes(); + let first_file_offset = sealed + .windows(b"{\"payload\"".len()) + .position(|window| window == b"{\"payload\"") + .expect("synthetic file object is present"); + let files_end_offset = first_file_offset + .checked_add(file.len()) + .expect("synthetic files end fits usize"); + + let layout = scan_layout( + &mut Cursor::new(&sealed), + u64::try_from(sealed.len()).expect("synthetic seal length fits u64"), + None, + &ActiveControl, + ) + .expect("synthetic seal has a valid lexical layout"); + + assert_eq!(layout.state_digest, state_digest); + assert_eq!(layout.format_revision, 6); + assert_eq!(layout.file_count, 1); + assert_eq!( + layout.first_file_offset, + u64::try_from(first_file_offset).expect("synthetic file offset fits u64") + ); + assert_eq!( + layout.files_end_offset, + u64::try_from(files_end_offset).expect("synthetic files end fits u64") + ); + assert_eq!( + layout.maximum_file_bytes, + u64::try_from(file.len()).expect("synthetic file length fits u64") + ); + assert!( + layout.structural_byte_visits < 1024, + "an 8 MiB JSON string should require bounded structural visits, observed {}", + layout.structural_byte_visits + ); + } + + #[test] + fn layout_scan_preserves_digest_and_file_boundaries_across_escaped_syntax() { + let first_file = r#"{"payload":"escaped \\\" quote and { [ ] } syntax"}"#; + let second_file = format!(r#"{{"payload":"{}"}}"#, "y".repeat(96 * 1024)); + let generation = format!( + r#"{{"format_revision":6,"files":[{first_file},{second_file}],"tail":"done"}}"# + ); + let state_digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(generation.as_bytes())) + .expect("synthetic generation digest is canonical"); + let sealed = format!( + r#"{{"state_digest":"{}","generation":{generation}}}"#, + state_digest.as_str() + ) + .into_bytes(); + let file_digest = ManifestDigest::from_sha256_bytes(&Sha256::digest(&sealed)) + .expect("synthetic file digest is canonical"); + let first_file_offset = sealed + .windows(first_file.len()) + .position(|window| window == first_file.as_bytes()) + .expect("first synthetic file is present"); + let files_end_offset = first_file_offset + first_file.len() + 1 + second_file.len(); + + let layout = scan_layout( + &mut Cursor::new(&sealed), + u64::try_from(sealed.len()).expect("synthetic seal length fits u64"), + Some(&file_digest), + &ActiveControl, + ) + .expect("escaped syntax does not alter the authenticated layout"); + + assert_eq!(layout.state_digest, state_digest); + assert_eq!(layout.file_count, 2); + assert_eq!(layout.first_file_offset, first_file_offset as u64); + assert_eq!(layout.files_end_offset, files_end_offset as u64); + assert_eq!(layout.maximum_file_bytes, second_file.len() as u64); + } + + #[test] + fn layout_scan_rejects_cancelled_and_corrupted_sources() { + let file = format!(r#"{{"payload":"{}"}}"#, "z".repeat(512 * 1024)); + let generation = format!(r#"{{"format_revision":6,"files":[{file}]}}"#); + let state_digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(generation.as_bytes())) + .expect("synthetic generation digest is canonical"); + let sealed = format!( + r#"{{"state_digest":"{}","generation":{generation}}}"#, + state_digest.as_str() + ) + .into_bytes(); + + let cancellation = CancelDuringStaging::new(); + let cancelled = match scan_layout( + &mut Cursor::new(&sealed), + sealed.len() as u64, + None, + &cancellation, + ) { + Ok(_) => panic!("layout opening must honor bounded read checkpoints"), + Err(error) => error, + }; + assert!(matches!( + cancelled, + CodeIndexProductionErrorV1::Interrupted(CodeIndexInterruptionV1::Cancelled) + )); + + let mut corrupted = sealed; + let payload = corrupted + .windows(b"zzzz".len()) + .position(|window| window == b"zzzz") + .expect("synthetic payload is present"); + corrupted[payload] = b'x'; + let error = match scan_layout( + &mut Cursor::new(&corrupted), + corrupted.len() as u64, + None, + &ActiveControl, + ) { + Ok(_) => panic!("payload corruption must fail the exact generation digest"), + Err(error) => error, + }; + assert!(matches!(error, CodeIndexProductionErrorV1::Contract(_))); + } + + #[test] + fn layout_scanner_retains_a_constant_string_window() { + let file = format!(r#"{{"payload":"{}"}}"#, "w".repeat(8 * 1024 * 1024)); + let generation = format!(r#"{{"format_revision":6,"files":[{file}]}}"#); + let state_digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(generation.as_bytes())) + .expect("synthetic generation digest is canonical"); + let sealed = format!( + r#"{{"state_digest":"{}","generation":{generation}}}"#, + state_digest.as_str() + ) + .into_bytes(); + let mut scanner = LayoutScanner::default(); + for (chunk_ordinal, chunk) in sealed.chunks(64 * 1024).enumerate() { + scanner + .observe_slice(chunk, (chunk_ordinal * 64 * 1024) as u64) + .expect("bounded chunk scan succeeds"); + assert!(scanner.string_len <= scanner.string.len()); + assert!(std::mem::size_of::() < 1024); + } + let layout = scanner.finish().expect("bounded scanner layout verifies"); + assert_eq!(layout.file_count, 1); + assert_eq!(layout.state_digest, state_digest); + } + + #[test] + fn layout_scan_does_not_allocate_for_unrelated_short_strings() { + let values = (0..50_000) + .map(|index| format!(r#""term-{index}""#)) + .collect::>() + .join(","); + let file = format!(r#"{{"payload":[{values}]}}"#); + let generation = format!(r#"{{"format_revision":6,"files":[{file}]}}"#); + let state_digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(generation.as_bytes())) + .expect("synthetic generation digest is canonical"); + let sealed = format!( + r#"{{"state_digest":"{}","generation":{generation}}}"#, + state_digest.as_str() + ) + .into_bytes(); + + let layout = scan_layout( + &mut Cursor::new(&sealed), + sealed.len() as u64, + None, + &ActiveControl, + ) + .expect("short-string-heavy layout verifies"); + + assert!( + layout.temporary_string_allocations <= 1, + "only the authenticated state digest may require a temporary string, observed {} allocations", + layout.temporary_string_allocations + ); + } +} diff --git a/crates/tracedecay-code-index/src/production/mod.rs b/crates/tracedecay-code-index/src/production/mod.rs index 8cb1b179cf..34f46dbc9f 100644 --- a/crates/tracedecay-code-index/src/production/mod.rs +++ b/crates/tracedecay-code-index/src/production/mod.rs @@ -76,9 +76,11 @@ mod generation_statistics; pub use generation_statistics::CodeIndexGenerationStatisticsV1; mod lexical_page_source; pub use lexical_page_source::{ - VerifiedSealedLexicalCursorV1, VerifiedSealedLexicalPageReadV1, + VerifiedSealedLexicalCursorV1, VerifiedSealedLexicalPageBatchBoundsV1, + VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, - VerifiedSealedLexicalSourceReceiptV1, + VerifiedSealedLexicalSourceReceiptV1, VerifiedSealedLexicalSymbolDisplayV1, + VerifiedSealedTextGenerationMetadataV1, }; mod sealed_codec; pub use sealed_codec::{ @@ -122,7 +124,10 @@ impl CodeIndexProductionConfigV1 { #[derive(Clone, Debug, PartialEq, Eq)] pub struct CodeIndexCapturedFileV1 { pub file_occurrence_id: FileOccurrenceId, - pub sanitized_bytes: Vec, + /// Canonical sanitized source allocation retained by the snapshot while + /// production reads it. Domain intake materializes only bounded per-file + /// `Vec` inputs where its serializable contract requires ownership. + pub sanitized_bytes: Arc<[u8]>, pub sensitivity_level: SensitivityLevelV1, } @@ -261,10 +266,10 @@ struct FileGenerationArtifactsV1 { } enum IncrementFileMaterializationV1 { - CarryForward(FileGenerationArtifactsV1), + CarryForward(Arc), ReExtracted { reuse_key: ManifestDigest, - artifact: FileGenerationArtifactsV1, + artifact: Arc, fallback: bool, }, Deleted, @@ -295,7 +300,6 @@ const MAX_PHYSICAL_CODE_ARTIFACTS: usize = 1_024; /// it unwind out of the pool instead aborted the whole fan-out and surfaced in /// the daemon only as an opaque `JoinError`, so a single malformed file took /// down every other file's work in the same generation. -#[hotpath::measure] fn collect_bounded_ordered(items: &[T], operation: F) -> Result, E> where T: Sync, @@ -341,11 +345,15 @@ where pub struct PhysicalCodeArtifactPoolStatsV1 { pub inserted: u64, pub reused: u64, + /// Artifact allocations still owned by a published or staged generation. + /// The physical pool indexes these allocations weakly and never extends + /// their lifetime. + pub resident: u64, } #[derive(Default)] struct PhysicalCodeArtifactPoolStateV1 { - artifacts: BTreeMap>, + artifacts: BTreeMap>, insertion_order: VecDeque, inserted: u64, reused: u64, @@ -359,62 +367,64 @@ pub struct SharedPhysicalCodeArtifactPoolV1 { state: Arc>, } -#[hotpath::measure] -fn clone_arc_under_lock( +fn upgrade_weak_under_lock( state: &Mutex, - select: impl FnOnce(&S) -> Option>, + select: impl FnOnce(&S) -> Option>, ) -> Option> { let state = state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - select(&state) + select(&state).and_then(|value| value.upgrade()) } impl SharedPhysicalCodeArtifactPoolV1 { - #[hotpath::measure] fn reuse( &self, key: &ManifestDigest, file: &ReceiptBoundCodeFileV1, worker: &crate::hotpath_observe::WorkerBusyGuard, - ) -> Option { - let artifact = { - let _coordination = worker.pool_coordination(); - clone_arc_under_lock(&self.state, |state| state.artifacts.get(key).cloned()) - }?; - let rebound = artifact.rematerialize_for_file(file).ok()?; - { - let _coordination = worker.pool_coordination(); + ) -> Option> { + crate::hotpath_observe::measure_hot_loop!("code_index.artifact_pool.reuse", { + let artifact = { + let _coordination = worker.pool_coordination(); + upgrade_weak_under_lock(&self.state, |state| state.artifacts.get(key).cloned()) + }?; + let rebound = Arc::new(artifact.rematerialize_for_file(file).ok()?); + { + let _coordination = worker.pool_coordination(); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.reused = state.reused.saturating_add(1); + } + Some(rebound) + }) + } + + /// Record one generation-owned artifact under its physical reuse key. + /// The pool retains only a weak index entry, so indexing a cold generation + /// never deep-clones or pins the parsed/chunked payload. + fn insert(&self, key: ManifestDigest, artifact: &Arc) { + crate::hotpath_observe::measure_hot_loop!("code_index.artifact_pool.insert", { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - state.reused = state.reused.saturating_add(1); - } - Some(rebound) - } - - /// Record one artifact under its physical reuse key. The artifact is - /// cloned only when the key is actually admitted, so re-recording an - /// already-pooled key (every warm rebuild) costs a lock, not a deep copy. - #[hotpath::measure] - fn insert(&self, key: ManifestDigest, artifact: &FileGenerationArtifactsV1) { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if state.artifacts.contains_key(&key) { - return; - } - while state.artifacts.len() >= MAX_PHYSICAL_CODE_ARTIFACTS { - let Some(evicted) = state.insertion_order.pop_front() else { - break; - }; - state.artifacts.remove(&evicted); - } - state.insertion_order.push_back(key.clone()); - state.artifacts.insert(key, Arc::new(artifact.clone())); - state.inserted = state.inserted.saturating_add(1); + if let Some(retained) = state.artifacts.get_mut(&key) { + *retained = Arc::downgrade(artifact); + return; + } + while state.artifacts.len() >= MAX_PHYSICAL_CODE_ARTIFACTS { + let Some(evicted) = state.insertion_order.pop_front() else { + break; + }; + state.artifacts.remove(&evicted); + } + state.insertion_order.push_back(key.clone()); + state.artifacts.insert(key, Arc::downgrade(artifact)); + state.inserted = state.inserted.saturating_add(1); + }) } pub fn stats(&self) -> PhysicalCodeArtifactPoolStatsV1 { @@ -425,31 +435,40 @@ impl SharedPhysicalCodeArtifactPoolV1 { PhysicalCodeArtifactPoolStatsV1 { inserted: state.inserted, reused: state.reused, + resident: u64::try_from( + state + .artifacts + .values() + .filter(|artifact| artifact.strong_count() > 0) + .count(), + ) + .unwrap_or(u64::MAX), } } } impl FileGenerationArtifactsV1 { - #[hotpath::measure] fn rematerialize_for_file( &self, file: &ReceiptBoundCodeFileV1, ) -> Result { - let target = file.validated_file(); - let artifacts = self.artifacts.rematerialize_for_generation( - target.generation_id.clone(), - target.file.file_occurrence_id.clone(), - )?; - let exact_authority = self - .exact_authority - .rematerialize_for_generation(&self.artifacts.chunks, &artifacts.chunks)?; - let extraction = rebind_extraction_batch(&self.authority, &self.extraction, file) - .map_err(|_| ChunkingFailureV1::GenerationMismatch)?; - Ok(Self { - authority: file.authority().clone(), - extraction, - artifacts, - exact_authority, + crate::hotpath_observe::measure_hot_loop!("code_index.artifact_pool.rematerialize", { + let target = file.validated_file(); + let artifacts = self.artifacts.rematerialize_for_generation( + target.generation_id.clone(), + target.file.file_occurrence_id.clone(), + )?; + let exact_authority = self + .exact_authority + .rematerialize_for_generation(&self.artifacts.chunks, &artifacts.chunks)?; + let extraction = rebind_extraction_batch(&self.authority, &self.extraction, file) + .map_err(|_| ChunkingFailureV1::GenerationMismatch)?; + Ok(Self { + authority: file.authority().clone(), + extraction, + artifacts, + exact_authority, + }) }) } } @@ -465,7 +484,7 @@ pub struct CodeIndexPublishedGenerationV1 { snapshot: SanitizedCodeSnapshotV1, repository_parse_identity: CodeIndexRepositoryParseIdentityV1, ignored_source_roster: IgnoredSourceRosterV1, - files: Vec, + files: Vec>, chunks: GenerationChunkManifestV1, symbols: GenerationSymbolIndexV1, lineage: Vec, @@ -965,7 +984,7 @@ impl CodeIndexPublishedGenerationV1 { .validate() .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - let mut files = self.files.iter().collect::>(); + let mut files = self.files.iter().map(Arc::as_ref).collect::>(); files.sort_by(|left, right| { left.artifacts .chunks @@ -987,41 +1006,44 @@ impl CodeIndexPublishedGenerationV1 { .iter() .map(|candidate| (&candidate.file_occurrence_id, candidate)) .collect::>(); - collect_bounded_ordered(&files, |file, _worker| { - file.artifacts - .validate() - .map_err(CodeIndexProductionErrorV1::Chunk)?; - let occurrence = occurrences_by_id - .get(&file.artifacts.chunks.document.file_occurrence_id) - .copied(); - if file.authority.project_id != self.manifest.project_id { - return Err(CodeIndexProductionErrorV1::Contract( - "published file authority project does not match the generation manifest" - .to_owned(), - )); - } - if file.authority.repository_id != self.snapshot.repository - || file.authority.worktree_id != self.snapshot.worktree - || file.authority.reference != self.snapshot.reference - || occurrence.is_none_or(|occurrence| { - occurrence.logical_path != file.authority.logical_path - || occurrence.content_digest != file.authority.content_digest - }) - || file.extraction.content_digest != file.authority.content_digest - || file.extraction.generation_id != self.manifest.generation_id - || file.extraction.file_occurrence_id - != file.artifacts.chunks.document.file_occurrence_id - { - return Err(CodeIndexProductionErrorV1::Contract( + hotpath::measure_block!( + "code_index.collect.validate_files", + collect_bounded_ordered(&files, |file, _worker| { + file.artifacts + .validate() + .map_err(CodeIndexProductionErrorV1::Chunk)?; + let occurrence = occurrences_by_id + .get(&file.artifacts.chunks.document.file_occurrence_id) + .copied(); + if file.authority.project_id != self.manifest.project_id { + return Err(CodeIndexProductionErrorV1::Contract( + "published file authority project does not match the generation manifest" + .to_owned(), + )); + } + if file.authority.repository_id != self.snapshot.repository + || file.authority.worktree_id != self.snapshot.worktree + || file.authority.reference != self.snapshot.reference + || occurrence.is_none_or(|occurrence| { + occurrence.logical_path != file.authority.logical_path + || occurrence.content_digest != file.authority.content_digest + }) + || file.extraction.content_digest != file.authority.content_digest + || file.extraction.generation_id != self.manifest.generation_id + || file.extraction.file_occurrence_id + != file.artifacts.chunks.document.file_occurrence_id + { + return Err(CodeIndexProductionErrorV1::Contract( "extraction authority does not match its published project, repository, scope, path, or content" .to_owned(), )); - } - file.exact_authority - .validate_all(&file.artifacts.chunks.chunks) - .map_err(CodeIndexProductionErrorV1::Chunk)?; - Ok(()) - })?; + } + file.exact_authority + .validate_all(&file.artifacts.chunks.chunks) + .map_err(CodeIndexProductionErrorV1::Chunk)?; + Ok(()) + }) + )?; validate_import_evidence(&files, &self.imports)?; let mut chunks = files .iter() @@ -1249,7 +1271,7 @@ where request: CodeIndexBuildRequestV1, control: &dyn CodeIndexExecutionControlV1, ) -> Result, CodeIndexProductionErrorV1> { - let started = crate::hotpath_observe::start_ttfq(); + let started = crate::hotpath_observe::start_build_to_queryable(); crate::hotpath_observe::record_generation_state("building"); crate::hotpath_observe::record_rebuild_state("unknown"); Self::checkpoint(control)?; @@ -1448,7 +1470,7 @@ where self.publication .publish_atomically(&scope, expected.as_ref(), Arc::clone(&candidate))?; crate::hotpath_observe::record_generation_state("queryable"); - crate::hotpath_observe::record_ttfq(started); + crate::hotpath_observe::record_build_to_queryable(started); Ok(candidate) } @@ -1497,7 +1519,6 @@ where /// snapshot order after the parallel sweep, and the key binds the same /// inputs either way, so recomputing it per recording was pure waste. #[allow(clippy::too_many_arguments)] - #[hotpath::measure] fn extract_file( config: &CodeIndexProductionConfigV1, physical_artifacts: &SharedPhysicalCodeArtifactPoolV1, @@ -1512,148 +1533,155 @@ where captured_files: &BTreeMap, control: &dyn CodeIndexExecutionControlV1, worker: &crate::hotpath_observe::WorkerBusyGuard, - ) -> Result<(ManifestDigest, FileGenerationArtifactsV1), CodeIndexProductionErrorV1> { - Self::checkpoint(control)?; - let captured = captured_files - .get(&file.file_occurrence_id) - .ok_or(CodeIndexInputErrorV1::MissingCapturedFile)?; - let receipt_bound = intake - .bind_file( - capability, - &config.project_id, - ValidatedCodeFileV1 { - generation_id: manifest.generation_id.clone(), - file: file.clone(), - snapshot_digest: capability.snapshot().intake_digest.clone(), - sanitized_bytes: captured.sanitized_bytes.clone(), - }, - ) - .map_err(CodeIndexProductionErrorV1::Intake)?; - let language = file.language.as_ref().ok_or_else(|| { - CodeIndexProductionErrorV1::Contract( - "present snapshot file has no declared language".to_owned(), - ) - })?; - let descriptor = intake.registry().descriptor(language).ok_or_else(|| { - CodeIndexProductionErrorV1::Contract( - "validated snapshot language has no descriptor".to_owned(), - ) - })?; - let physical_reuse_key = - Self::physical_reuse_key(config, file, descriptor, captured.sensitivity_level)?; - if let Some(reused) = physical_artifacts.reuse(&physical_reuse_key, &receipt_bound, worker) - { - crate::hotpath_observe::add_reused_parses(1); + ) -> Result<(ManifestDigest, Arc), CodeIndexProductionErrorV1> { + crate::hotpath_observe::measure_hot_loop!("code_index.materialize.file", { Self::checkpoint(control)?; - return Ok((physical_reuse_key, reused)); - } - let snapshot = &capability.snapshot().snapshot; - let parser = extractor - .resolve_parser(receipt_bound.validated_file(), descriptor) - .ok_or_else(|| { - CodeIndexProductionErrorV1::Extraction(ExtractionFailureV1::GrammarUnavailable { - language: descriptor.language.clone(), - }) + let captured = captured_files + .get(&file.file_occurrence_id) + .ok_or(CodeIndexInputErrorV1::MissingCapturedFile)?; + let receipt_bound = intake + .bind_file( + capability, + &config.project_id, + ValidatedCodeFileV1 { + generation_id: manifest.generation_id.clone(), + file: file.clone(), + snapshot_digest: capability.snapshot().intake_digest.clone(), + sanitized_bytes: captured.sanitized_bytes.to_vec(), + }, + ) + .map_err(CodeIndexProductionErrorV1::Intake)?; + let language = file.language.as_ref().ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "present snapshot file has no declared language".to_owned(), + ) })?; - if crate::languages::canonical_language_id(parser.language_name()) - != descriptor.language.as_str() - { - return Err(CodeIndexProductionErrorV1::Extraction( - ExtractionFailureV1::IncompatibleDescriptor { - detail: format!( - "descriptor {} resolved to a {} parser", - descriptor.language, - parser.language_name() - ), - }, - )); - } - let cancellation = ExtractionControlBridge { control }; - let extraction = match parse_for_indexing( - retained_parses, - config, - snapshot, - repository_parse_identity, - file, - captured, - parser, - ) { - Ok((parse_artifacts, parsed_len)) => { + let descriptor = intake.registry().descriptor(language).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "validated snapshot language has no descriptor".to_owned(), + ) + })?; + let physical_reuse_key = + Self::physical_reuse_key(config, file, descriptor, captured.sensitivity_level)?; + if let Some(reused) = + physical_artifacts.reuse(&physical_reuse_key, &receipt_bound, worker) + { + crate::hotpath_observe::add_reused_parses(1); Self::checkpoint(control)?; - extractor - .extract_preparsed( - &receipt_bound, - descriptor, - parse_artifacts, - parsed_len, - &cancellation, - ) - .map_err(|error| match error { - ExtractionFailureV1::Cancelled | ExtractionFailureV1::TimedOut => { - Self::interruption_error(control) - } - error => CodeIndexProductionErrorV1::Extraction(error), - })? + return Ok((physical_reuse_key, reused)); } - // One file exceeding the bounded parse budget is evidence about - // that file, never about the generation: record it as a typed - // unsupported document with a reason and keep building, instead - // of failing the whole reconcile cycle and leaving the served - // generation permanently stale. - Err(CodeIndexProductionErrorV1::RetainedParse(ParseError::TimedOut { .. })) => { - Self::checkpoint(control)?; - extractor - .extract_parse_timed_out(&receipt_bound, descriptor) - .map_err(CodeIndexProductionErrorV1::Extraction)? + let snapshot = &capability.snapshot().snapshot; + let parser = extractor + .resolve_parser(receipt_bound.validated_file(), descriptor) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Extraction( + ExtractionFailureV1::GrammarUnavailable { + language: descriptor.language.clone(), + }, + ) + })?; + if crate::languages::canonical_language_id(parser.language_name()) + != descriptor.language.as_str() + { + return Err(CodeIndexProductionErrorV1::Extraction( + ExtractionFailureV1::IncompatibleDescriptor { + detail: format!( + "descriptor {} resolved to a {} parser", + descriptor.language, + parser.language_name() + ), + }, + )); } - Err(error) => return Err(error), - }; - Self::checkpoint(control)?; - let (artifacts, exact_authority) = chunker - .index_file_with_authority_from_extraction( - &receipt_bound, - &extraction, - descriptor, - captured.sensitivity_level, - &cancellation, - ) - .map_err(|error| match error { - ChunkingFailureV1::Cancelled => Self::interruption_error(control), - error => CodeIndexProductionErrorV1::Chunk(error), - })?; - Self::checkpoint(control)?; - let (authority, extraction, _) = extraction.into_parts(); - let artifact = FileGenerationArtifactsV1 { - authority, - extraction, - artifacts, - exact_authority, - }; - Ok((physical_reuse_key, artifact)) + let cancellation = ExtractionControlBridge { control }; + let extraction = match parse_for_indexing( + retained_parses, + config, + snapshot, + repository_parse_identity, + file, + captured, + parser, + ) { + Ok((parse_artifacts, parsed_len)) => { + Self::checkpoint(control)?; + extractor + .extract_preparsed( + &receipt_bound, + descriptor, + parse_artifacts, + parsed_len, + &cancellation, + ) + .map_err(|error| match error { + ExtractionFailureV1::Cancelled | ExtractionFailureV1::TimedOut => { + Self::interruption_error(control) + } + error => CodeIndexProductionErrorV1::Extraction(error), + })? + } + // One file exceeding the bounded parse budget is evidence about + // that file, never about the generation: record it as a typed + // unsupported document with a reason and keep building, instead + // of failing the whole reconcile cycle and leaving the served + // generation permanently stale. + Err(CodeIndexProductionErrorV1::RetainedParse(ParseError::TimedOut { .. })) => { + Self::checkpoint(control)?; + extractor + .extract_parse_timed_out(&receipt_bound, descriptor) + .map_err(CodeIndexProductionErrorV1::Extraction)? + } + Err(error) => return Err(error), + }; + Self::checkpoint(control)?; + let (artifacts, exact_authority) = chunker + .index_file_with_authority_from_extraction( + &receipt_bound, + &extraction, + descriptor, + captured.sensitivity_level, + &cancellation, + ) + .map_err(|error| match error { + ChunkingFailureV1::Cancelled => Self::interruption_error(control), + error => CodeIndexProductionErrorV1::Chunk(error), + })?; + Self::checkpoint(control)?; + let (authority, extraction, _) = extraction.into_parts(); + let artifact = Arc::new(FileGenerationArtifactsV1 { + authority, + extraction, + artifacts, + exact_authority, + }); + Ok((physical_reuse_key, artifact)) + }) } - #[hotpath::measure] fn physical_reuse_key( config: &CodeIndexProductionConfigV1, file: &SanitizedCodeFileV1, descriptor: &tracedecay_domain::LanguageDescriptorV1, sensitivity_level: SensitivityLevelV1, ) -> Result { - canonical_sha256(&( - PHYSICAL_CODE_ARTIFACT_REUSE_DIGEST_DOMAIN, - &config.project_id, - &config.repository, - &file.logical_path, - &file.content_digest, - descriptor, - &config.sanitizer_revision, - &config.policy_revision, - &config.chunker_revision, - &config.privacy_domain, - config.privacy_key_epoch, - sensitivity_level, - )) - .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string())) + crate::hotpath_observe::measure_hot_loop!("code_index.materialize.reuse_key", { + canonical_sha256(&( + PHYSICAL_CODE_ARTIFACT_REUSE_DIGEST_DOMAIN, + &config.project_id, + &config.repository, + &file.file_occurrence_id, + &file.logical_path, + &file.content_digest, + descriptor, + &config.sanitizer_revision, + &config.policy_revision, + &config.chunker_revision, + &config.privacy_domain, + config.privacy_key_epoch, + sensitivity_level, + )) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string())) + }) } #[allow(clippy::too_many_arguments)] @@ -1678,23 +1706,26 @@ where let config = &self.config; let physical_artifacts = &self.physical_artifacts; let retained_parses = &self.retained_parses; - let extracted = collect_bounded_ordered(&present_files, |file, worker| { - Self::extract_file( - config, - physical_artifacts, - retained_parses, - intake, - capability, - manifest, - extractor, - chunker, - repository_parse_identity, - file, - captured_files, - control, - worker, - ) - })?; + let extracted = hotpath::measure_block!( + "code_index.collect.materialize_full", + collect_bounded_ordered(&present_files, |file, worker| { + Self::extract_file( + config, + physical_artifacts, + retained_parses, + intake, + capability, + manifest, + extractor, + chunker, + repository_parse_identity, + file, + captured_files, + control, + worker, + ) + }) + )?; // Parallel completion order is intentionally not cache authority. // Record artifacts in canonical snapshot order so bounded eviction and // subsequent physical reuse remain deterministic. @@ -1743,11 +1774,13 @@ where let config = &self.config; let physical_artifacts = &self.physical_artifacts; let retained_parses = &self.retained_parses; - let file_materializations = collect_bounded_ordered( - &increment.files, - |file_plan, - worker| - -> Result { + let file_materializations = hotpath::measure_block!( + "code_index.collect.materialize_increment", + collect_bounded_ordered( + &increment.files, + |file_plan, + worker| + -> Result { Self::checkpoint(control)?; match &file_plan.action { FileExtractionActionV1::CarryForward { @@ -1780,13 +1813,15 @@ where generation_id: manifest.generation_id.clone(), file: (**current_file).clone(), snapshot_digest: capability.snapshot().intake_digest.clone(), - sanitized_bytes: captured.sanitized_bytes.clone(), + sanitized_bytes: captured.sanitized_bytes.to_vec(), }, ) .map_err(CodeIndexProductionErrorV1::Intake)?; if let Ok(artifact) = prior.rematerialize_for_file(&receipt_bound) { crate::hotpath_observe::add_reused_parses(1); - Ok(IncrementFileMaterializationV1::CarryForward(artifact)) + Ok(IncrementFileMaterializationV1::CarryForward(Arc::new( + artifact, + ))) } else { // Opaque exact evidence may refuse generation-local // occurrence rebinding. Re-extract through the parser @@ -1840,7 +1875,8 @@ where Ok(IncrementFileMaterializationV1::Deleted) } } - }, + }, + ) )?; let mut files = Vec::new(); diff --git a/crates/tracedecay-code-index/src/production/parser_artifacts.rs b/crates/tracedecay-code-index/src/production/parser_artifacts.rs index e826b007be..284dcf6f34 100644 --- a/crates/tracedecay-code-index/src/production/parser_artifacts.rs +++ b/crates/tracedecay-code-index/src/production/parser_artifacts.rs @@ -11,7 +11,7 @@ use super::{ CodeIndexRepositoryParseIdentityV1, }; -#[hotpath::measure] +#[hotpath::measure(label = "code_index.extract.parser_artifact")] pub(super) fn parse_for_indexing( retained_parses: &SharedRetainedParsePool, config: &CodeIndexProductionConfigV1, diff --git a/crates/tracedecay-code-index/src/production/sealed_codec.rs b/crates/tracedecay-code-index/src/production/sealed_codec.rs index 4aa3e13329..c086c62557 100644 --- a/crates/tracedecay-code-index/src/production/sealed_codec.rs +++ b/crates/tracedecay-code-index/src/production/sealed_codec.rs @@ -1,4 +1,4 @@ -use std::io::{Read, Seek, SeekFrom, Write}; +use std::io::{BufWriter, Read, Seek, SeekFrom, Write}; #[cfg(test)] use serde::de::DeserializeOwned; @@ -138,9 +138,10 @@ struct SealedPublishedGenerationEnvelopeV1 { } #[derive(Deserialize)] -struct SealedPublishedGenerationRawEnvelopeV1 { +struct SealedPublishedGenerationRawEnvelopeV1<'a> { state_digest: ManifestDigest, - generation: Box, + #[serde(borrow)] + generation: &'a RawValue, } #[derive(Deserialize)] @@ -185,7 +186,23 @@ fn read_admitted_bytes( Ok(bytes) } -const SEALED_GENERATION_WRITE_CHUNK_BYTES_V1: usize = 64 * 1024; +fn admit_sealed_generation_bytes( + bytes: &[u8], + admitted_len: u64, +) -> Result<&[u8], CodeIndexProductionErrorV1> { + admit_sealed_generation_len(admitted_len)?; + let actual_len = u64::try_from(bytes.len()).map_err(|_| { + CodeIndexProductionErrorV1::Contract("sealed generation length exceeds u64".to_owned()) + })?; + if actual_len != admitted_len { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation length does not match its admitted length".to_owned(), + )); + } + Ok(bytes) +} + +const SEALED_GENERATION_WRITE_CHUNK_BYTES_V1: usize = 1024 * 1024; struct BoundedChunkWriterV1<'a, W> { writer: &'a mut W, @@ -279,9 +296,10 @@ fn write_generation_envelope_with_limits( "sealed generation writer position failed: {error}" )) })?; + let mut writer = BufWriter::with_capacity(maximum_write, writer); let (digest_start, digest_end, generation_hash, written) = { let mut bounded = BoundedChunkWriterV1 { - writer, + writer: &mut writer, written: 0, byte_limit, maximum_write, @@ -349,6 +367,11 @@ fn write_generation_envelope_with_limits( )) } })?; + bounded.flush().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation serialization flush failed: {error}" + )) + })?; (digest_start, digest_end, generation_hash, bounded.written) }; @@ -379,11 +402,16 @@ fn write_generation_envelope_with_limits( "sealed generation digest seek failed: {error}" )) })?; - write_chunked(writer, &digest_bytes, maximum_write).map_err(|error| { + write_chunked(&mut writer, &digest_bytes, maximum_write).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "sealed generation digest serialization failed: {error}" )) })?; + writer.flush().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation digest flush failed: {error}" + )) + })?; let envelope_end = envelope_start.checked_add(written).ok_or_else(|| { CodeIndexProductionErrorV1::Contract( "sealed generation writer position overflowed".to_owned(), @@ -396,6 +424,11 @@ fn write_generation_envelope_with_limits( "sealed generation final seek failed: {error}" )) })?; + writer.flush().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation final flush failed: {error}" + )) + })?; Ok(written) } @@ -472,52 +505,77 @@ impl CodeIndexPublishedGenerationV1 { /// Restore and revalidate a complete sealed generation. #[hotpath::measure] pub fn decode_sealed(bytes: &[u8]) -> Result { - crate::hotpath_observe::record_seal_bytes(bytes.len() as u64); let admitted_len = u64::try_from(bytes.len()).map_err(|_| { CodeIndexProductionErrorV1::Contract("sealed generation length exceeds u64".to_owned()) })?; - Self::decode_sealed_reader(std::io::Cursor::new(bytes), admitted_len) + Self::decode_admitted_sealed_bytes(bytes, admitted_len) } pub fn decode_sealed_reader( reader: R, admitted_len: u64, ) -> Result { - let bytes = read_admitted_bytes(reader, admitted_len)?; - let probe: SealedPublishedGenerationFormatProbeV1 = serde_json::from_slice(&bytes) - .map_err(|error| { + let bytes = hotpath::measure_block!( + "code_index.sealed_decode.admitted_read", + read_admitted_bytes(reader, admitted_len) + )?; + Self::decode_admitted_sealed_bytes(&bytes, admitted_len) + } + + fn decode_admitted_sealed_bytes( + bytes: &[u8], + admitted_len: u64, + ) -> Result { + let bytes = hotpath::measure_block!( + "code_index.sealed_decode.input_admission", + admit_sealed_generation_bytes(bytes, admitted_len) + )?; + crate::hotpath_observe::record_seal_bytes(admitted_len); + let probe: SealedPublishedGenerationFormatProbeV1 = hotpath::measure_block!( + "code_index.sealed_decode.envelope_parse", + serde_json::from_slice(bytes).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "sealed generation format probe failed: {error}" )) - })?; + }) + )?; let envelope = match probe.generation.format_revision { - LEGACY_CANONICAL_SEALED_GENERATION_FORMAT_REVISION => serde_json::from_slice::< - SealedPublishedGenerationEnvelopeV1, - >(&bytes) - .map_err(|error| { - CodeIndexProductionErrorV1::Contract(format!( - "sealed generation decoding failed: {error}" - )) - })?, + LEGACY_CANONICAL_SEALED_GENERATION_FORMAT_REVISION => hotpath::measure_block!( + "code_index.sealed_decode.persisted_materialization", + serde_json::from_slice::(bytes).map_err( + |error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation decoding failed: {error}" + )) + } + ) + )?, SEALED_GENERATION_FORMAT_REVISION_V1 => { - let raw: SealedPublishedGenerationRawEnvelopeV1 = serde_json::from_slice(&bytes) - .map_err(|error| { + let raw: SealedPublishedGenerationRawEnvelopeV1 = hotpath::measure_block!( + "code_index.sealed_decode.envelope_parse", + serde_json::from_slice(bytes).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "sealed generation decoding failed: {error}" )) - })?; - let expected_digest = json_generation_digest(raw.generation.get().as_bytes())?; + }) + )?; + let expected_digest = hotpath::measure_block!( + "code_index.sealed_decode.v6_payload_digest", + json_generation_digest(raw.generation.get().as_bytes()) + )?; if expected_digest != raw.state_digest { return Err(CodeIndexProductionErrorV1::Contract( "sealed generation state digest does not match its payload".to_owned(), )); } - let generation: PersistedPublishedGenerationV1 = + let generation: PersistedPublishedGenerationV1 = hotpath::measure_block!( + "code_index.sealed_decode.persisted_materialization", serde_json::from_str(raw.generation.get()).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "sealed generation payload decoding failed: {error}" )) - })?; + }) + )?; if generation.format_revision.0 != SEALED_GENERATION_FORMAT_REVISION_V1 { return Err(CodeIndexProductionErrorV1::Contract( "sealed generation format revision is incompatible".to_owned(), @@ -552,62 +610,93 @@ impl CodeIndexPublishedGenerationV1 { )); } - let repository_parse_identity = envelope.generation.repository_parse_identity; - let ignored_source_roster = IgnoredSourceRosterV1::restore( - &envelope.generation.snapshot, - &repository_parse_identity, - envelope.generation.ignored_source_admissions, - envelope.generation.ignored_source_admissions_digest, - )?; - - let mut files = Vec::with_capacity(envelope.generation.files.len()); - for file in envelope.generation.files { - let exact_authority = ExactExtractionAuthorityV1::restore(&file.artifacts.chunks) - .map_err(CodeIndexProductionErrorV1::Chunk)?; - files.push(FileGenerationArtifactsV1 { - authority: file.authority, - extraction: file.extraction, - artifacts: file.artifacts, - exact_authority, - }); - } - let chunks = GenerationChunkManifestV1::new( - envelope.generation.manifest.generation_id.clone(), - files - .iter() - .map(|file| file.artifacts.chunks.clone()) - .collect(), - ) - .map_err(CodeIndexProductionErrorV1::Increment)?; - let symbols = GenerationSymbolIndexV1::new( - envelope.generation.manifest.generation_id.clone(), - files - .iter() - .flat_map(|file| file.artifacts.symbols.clone()) - .collect(), - ) - .map_err(CodeIndexProductionErrorV1::Lineage)?; - let imports = derive_import_evidence(&files); - let (edges, edge_abstentions) = collect_edge_evidence(&files); - let projection = ProjectionPublicationHandoffV1::restore( - envelope.generation.projection_request, - envelope.generation.projection_receipt, - ) - .map_err(CodeIndexProductionErrorV1::Projection)?; + let PersistedPublishedGenerationV1 { + format_revision: _, + manifest, + snapshot, + repository_parse_identity, + ignored_source_admissions, + ignored_source_admissions_digest, + files: persisted_files, + lineage, + coverage, + capability, + projection_request, + projection_receipt, + } = envelope.generation; + let ( + ignored_source_roster, + files, + chunks, + symbols, + imports, + edges, + edge_abstentions, + projection, + ) = hotpath::measure_block!("code_index.sealed_decode.authority_restore", { + let ignored_source_roster = IgnoredSourceRosterV1::restore( + &snapshot, + &repository_parse_identity, + ignored_source_admissions, + ignored_source_admissions_digest, + )?; + let mut files = Vec::with_capacity(persisted_files.len()); + for file in persisted_files { + let exact_authority = ExactExtractionAuthorityV1::restore(&file.artifacts.chunks) + .map_err(CodeIndexProductionErrorV1::Chunk)?; + files.push(Arc::new(FileGenerationArtifactsV1 { + authority: file.authority, + extraction: file.extraction, + artifacts: file.artifacts, + exact_authority, + })); + } + let chunks = GenerationChunkManifestV1::new( + manifest.generation_id.clone(), + files + .iter() + .map(|file| file.artifacts.chunks.clone()) + .collect(), + ) + .map_err(CodeIndexProductionErrorV1::Increment)?; + let symbols = GenerationSymbolIndexV1::new( + manifest.generation_id.clone(), + files + .iter() + .flat_map(|file| file.artifacts.symbols.clone()) + .collect(), + ) + .map_err(CodeIndexProductionErrorV1::Lineage)?; + let imports = derive_import_evidence(&files); + let (edges, edge_abstentions) = collect_edge_evidence(&files); + let projection = + ProjectionPublicationHandoffV1::restore(projection_request, projection_receipt) + .map_err(CodeIndexProductionErrorV1::Projection)?; + Ok::<_, CodeIndexProductionErrorV1>(( + ignored_source_roster, + files, + chunks, + symbols, + imports, + edges, + edge_abstentions, + projection, + )) + })?; let generation = Self { - manifest: envelope.generation.manifest, - snapshot: envelope.generation.snapshot, + manifest, + snapshot, repository_parse_identity, ignored_source_roster, files, chunks, symbols, - lineage: envelope.generation.lineage, + lineage, imports, edges, edge_abstentions, - coverage: envelope.generation.coverage, - capability: envelope.generation.capability, + coverage, + capability, projection, validated: OnceLock::new(), admitted: OnceLock::new(), @@ -615,7 +704,10 @@ impl CodeIndexPublishedGenerationV1 { chunk_policy: OnceLock::new(), graph_manifest: OnceLock::new(), }; - generation.validate_fresh()?; + hotpath::measure_block!( + "code_index.sealed_decode.corpus_validation", + generation.validate_fresh() + )?; Ok(generation) } @@ -634,11 +726,53 @@ impl CodeIndexPublishedGenerationV1 { #[cfg(test)] mod tests { + use std::alloc::{GlobalAlloc, Layout, System}; + use std::cell::Cell; + use super::*; + struct LargestAllocationRecorderV1; + + thread_local! { + static LARGEST_ALLOCATION_BYTES: Cell = const { Cell::new(0) }; + } + + unsafe impl GlobalAlloc for LargestAllocationRecorderV1 { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + LARGEST_ALLOCATION_BYTES.with(|largest| largest.set(largest.get().max(layout.size()))); + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + LARGEST_ALLOCATION_BYTES.with(|largest| largest.set(largest.get().max(layout.size()))); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + LARGEST_ALLOCATION_BYTES.with(|largest| largest.set(largest.get().max(new_size))); + unsafe { System.realloc(ptr, layout, new_size) } + } + } + + #[global_allocator] + static TEST_ALLOCATOR: LargestAllocationRecorderV1 = LargestAllocationRecorderV1; + + fn measure_largest_allocation(work: impl FnOnce() -> T) -> (T, usize) { + LARGEST_ALLOCATION_BYTES.with(|largest| largest.set(0)); + let value = work(); + let largest = LARGEST_ALLOCATION_BYTES.with(Cell::get); + (value, largest) + } + struct MaximumWriteSink { inner: std::io::Cursor>, maximum_write: usize, + write_calls: usize, + largest_write: usize, } impl Write for MaximumWriteSink { @@ -646,6 +780,8 @@ mod tests { if bytes.len() > self.maximum_write { return Err(std::io::Error::other("write exceeded the fixture bound")); } + self.write_calls += 1; + self.largest_write = self.largest_write.max(bytes.len()); self.inner.write(bytes) } @@ -675,6 +811,8 @@ mod tests { let mut assembled = MaximumWriteSink { inner: std::io::Cursor::new(Vec::new()), maximum_write: 7, + write_calls: 0, + largest_write: 0, }; write_generation_envelope_with_limits(&generation, &mut assembled, u64::MAX, 7) .expect("direct sealed envelope encoding"); @@ -692,6 +830,31 @@ mod tests { assert_eq!(assembled, prior); } + #[test] + fn direct_envelope_encoding_coalesces_small_serialization_writes() { + const WRITE_BOUND: usize = 1024 * 1024; + let generation = serde_json::json!({ + "format_revision": SEALED_GENERATION_FORMAT_REVISION_V1, + "payload": vec![1_u8; WRITE_BOUND] + }); + let mut assembled = MaximumWriteSink { + inner: std::io::Cursor::new(Vec::new()), + maximum_write: WRITE_BOUND, + write_calls: 0, + largest_write: 0, + }; + + write_generation_envelope_with_limits(&generation, &mut assembled, u64::MAX, WRITE_BOUND) + .expect("direct sealed envelope encoding"); + + assert!( + assembled.write_calls <= 8, + "a two-megabyte seal must use coalesced writes, observed {}", + assembled.write_calls + ); + assert!(assembled.largest_write <= WRITE_BOUND); + } + #[test] fn direct_envelope_encoding_refuses_before_exceeding_its_byte_limit() { let generation = serde_json::json!({ @@ -711,6 +874,8 @@ mod tests { let mut refused = MaximumWriteSink { inner: std::io::Cursor::new(Vec::new()), maximum_write: 7, + write_calls: 0, + largest_write: 0, }; let error = write_generation_envelope_with_limits(&generation, &mut refused, byte_limit, 7) @@ -766,4 +931,57 @@ mod tests { if message.contains("admitted length") )); } + + #[test] + fn borrowed_decode_does_not_allocate_a_second_corpus_sized_buffer() { + const PADDING_BYTES: usize = 8 * 1024 * 1024; + let wrong_digest = ManifestDigest::from_sha256_bytes(&[0; 32]).expect("fixture digest"); + let mut sealed = format!( + "{{\"state_digest\":{},\"generation\":{{\"format_revision\":{},\"padding\":\"", + serde_json::to_string(&wrong_digest).expect("fixture digest serialization"), + SEALED_GENERATION_FORMAT_REVISION_V1, + ) + .into_bytes(); + sealed.resize(sealed.len() + PADDING_BYTES, b'x'); + sealed.extend_from_slice(b"\"}}"); + + let (result, largest_allocation) = + measure_largest_allocation(|| CodeIndexPublishedGenerationV1::decode_sealed(&sealed)); + + assert!(matches!( + result, + Err(CodeIndexProductionErrorV1::Contract(message)) + if message.contains("state digest does not match") + )); + assert!( + largest_allocation < sealed.len() / 2, + "borrowed decode allocated {largest_allocation} bytes for a {} byte sealed input", + sealed.len() + ); + } + + #[test] + fn raw_v6_payload_borrows_the_callers_admitted_bytes() { + const PADDING_BYTES: usize = 4 * 1024 * 1024; + let digest = ManifestDigest::from_sha256_bytes(&[0; 32]).expect("fixture digest"); + let mut sealed = format!( + "{{\"state_digest\":{},\"generation\":{{\"format_revision\":{},\"padding\":\"", + serde_json::to_string(&digest).expect("fixture digest serialization"), + SEALED_GENERATION_FORMAT_REVISION_V1, + ) + .into_bytes(); + sealed.resize(sealed.len() + PADDING_BYTES, b'x'); + sealed.extend_from_slice(b"\"}}"); + + let raw: SealedPublishedGenerationRawEnvelopeV1 = + serde_json::from_slice(&sealed).expect("raw envelope parses"); + let payload_start = raw.generation.get().as_ptr() as usize; + let admitted_start = sealed.as_ptr() as usize; + let admitted_end = admitted_start + sealed.len(); + + assert!( + (admitted_start..admitted_end).contains(&payload_start), + "the raw payload must point into the caller's admitted byte slice" + ); + } } diff --git a/crates/tracedecay-code-index/src/production/worker_tests.rs b/crates/tracedecay-code-index/src/production/worker_tests.rs index 67acba47f1..4bd693c715 100644 --- a/crates/tracedecay-code-index/src/production/worker_tests.rs +++ b/crates/tracedecay-code-index/src/production/worker_tests.rs @@ -1,5 +1,5 @@ use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Weak}; use super::*; @@ -16,16 +16,22 @@ impl From for WorkerTestError { } #[test] -fn cloned_arc_lookup_releases_the_mutex_before_downstream_work() { - let state = Mutex::new(Some(Arc::new(7_u8))); +fn upgraded_weak_lookup_releases_the_mutex_before_downstream_work() { + let owner = Arc::new(7_u8); + let state = Mutex::new(Some(Arc::downgrade(&owner))); - let value = clone_arc_under_lock(&state, |value| value.clone()).expect("pooled value"); + let value = upgrade_weak_under_lock(&state, |value| value.clone()).expect("pooled value"); + assert!( + Arc::ptr_eq(&owner, &value), + "weak cache lookup must recover the generation's exact allocation" + ); let unlocked = state .try_lock() .expect("pooled lookup must not retain the mutex"); assert_eq!(*value, 7); - assert_eq!(unlocked.as_deref(), Some(&7)); + let retained = unlocked.as_ref().and_then(Weak::upgrade); + assert_eq!(retained.as_deref(), Some(&7)); } #[test] diff --git a/crates/tracedecay-code-index/src/retained_parse.rs b/crates/tracedecay-code-index/src/retained_parse.rs index f9563cb0d0..bacb1060d4 100644 --- a/crates/tracedecay-code-index/src/retained_parse.rs +++ b/crates/tracedecay-code-index/src/retained_parse.rs @@ -171,7 +171,6 @@ impl SharedRetainedParsePool { .map(|(report, _)| report) } - #[hotpath::measure] pub fn parse_and_extract( &self, identity: ParseDocumentIdentity, @@ -179,23 +178,24 @@ impl SharedRetainedParsePool { source: &str, extractor: &dyn LanguageExtractor, ) -> Result<(ParseReport, ParsedExtraction), ParseError> { - let (report, extraction) = - self.parse_and_extract_artifact(identity, language_id, source, extractor)?; - Ok(( - report, - ParsedExtraction { - result: extraction.artifact.result, - disposition: extraction.disposition, - metrics: extraction.metrics, - }, - )) + crate::hotpath_observe::measure_hot_loop!("code_index.collect.retained", { + let (report, extraction) = + self.parse_and_extract_artifact(identity, language_id, source, extractor)?; + Ok(( + report, + ParsedExtraction { + result: extraction.artifact.result, + disposition: extraction.disposition, + metrics: extraction.metrics, + }, + )) + }) } /// Parse and extract one full canonical artifact from the pool-owned tree. /// The retained artifact, including import bindings, is the previous-state /// authority for incremental merging; this path never acquires a second /// parser. - #[hotpath::measure] pub fn parse_and_extract_artifact( &self, identity: ParseDocumentIdentity, @@ -203,23 +203,24 @@ impl SharedRetainedParsePool { source: &str, extractor: &dyn LanguageExtractor, ) -> Result<(ParseReport, ParsedExtractionArtifactV1), ParseError> { - let grammar_key = extractor.retained_grammar_key(identity.logical_path()); - let prepared_source = extractor.prepare_parse_source(source); - let (report, extraction) = self.parse_internal( - identity, - language_id, - source, - prepared_source.as_ref(), - Some(&grammar_key), - Some(extractor), - )?; - match extraction { - Some(extraction) => Ok((report, extraction)), - None => Err(ParseError::ParseFailed), - } + crate::hotpath_observe::measure_hot_loop!("code_index.collect.retained_artifact", { + let grammar_key = extractor.retained_grammar_key(identity.logical_path()); + let prepared_source = extractor.prepare_parse_source(source); + let (report, extraction) = self.parse_internal( + identity, + language_id, + source, + prepared_source.as_ref(), + Some(&grammar_key), + Some(extractor), + )?; + match extraction { + Some(extraction) => Ok((report, extraction)), + None => Err(ParseError::ParseFailed), + } + }) } - #[hotpath::measure] fn parse_internal( &self, identity: ParseDocumentIdentity, @@ -229,119 +230,122 @@ impl SharedRetainedParsePool { grammar_key: Option<&str>, extractor: Option<&dyn LanguageExtractor>, ) -> Result<(ParseReport, Option), ParseError> { - if source.len() > self.limits.max_total_source_bytes { - self.record_failure(); - return Err(ParseError::SourceTooLarge { - size: source.len(), - limit: self.limits.max_total_source_bytes, - }); - } - let key = ParseDocumentKey::for_identity(&identity); - let (existing, admission_epoch) = { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - touch(&mut state.lru, &key); - (state.documents.get(&key).cloned(), state.clear_epoch) - }; - - match existing { - Some(entry) => self.parse_existing( - key, - entry, - identity, - language_id, - source, - prepared_source, - grammar_key, - extractor, - ), - None => { - // Serialize first admission per document. Unrelated documents - // parse concurrently; a second lookup after acquiring this - // key's gate keeps one retained tree for duplicate callers. - let first_admission = self.first_admission(&key); - let _first_admission_guard = first_admission - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + crate::hotpath_observe::measure_hot_loop!("code_index.collect.parse", { + if source.len() > self.limits.max_total_source_bytes { + self.record_failure(); + return Err(ParseError::SourceTooLarge { + size: source.len(), + limit: self.limits.max_total_source_bytes, + }); + } + let key = ParseDocumentKey::for_identity(&identity); + let (existing, admission_epoch) = { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(entry) = state.documents.get(&key).cloned() { - touch(&mut state.lru, &key); - drop(state); - return self.parse_existing( - key, - entry, - identity, - language_id, - source, - prepared_source, - grammar_key, - extractor, - ); - } - drop(state); - let opened = match grammar_key { - Some(grammar_key) => RetainedParseDocument::open_prepared( - identity, - language_id, - grammar_key, - source, - prepared_source, - self.limits.document, - ), - None => RetainedParseDocument::open( - identity, - language_id, - source, - self.limits.document, - ), - }; - let (document, report) = match opened { - Ok(parsed) => parsed, - Err(error) => { - self.record_failure_at(admission_epoch); - return Err(error); + touch(&mut state.lru, &key); + (state.documents.get(&key).cloned(), state.clear_epoch) + }; + + match existing { + Some(entry) => self.parse_existing( + key, + entry, + identity, + language_id, + source, + prepared_source, + grammar_key, + extractor, + ), + None => { + // Serialize first admission per document. Unrelated documents + // parse concurrently; a second lookup after acquiring this + // key's gate keeps one retained tree for duplicate callers. + let first_admission = self.first_admission(&key); + let _first_admission_guard = first_admission + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(entry) = state.documents.get(&key).cloned() { + touch(&mut state.lru, &key); + drop(state); + return self.parse_existing( + key, + entry, + identity, + language_id, + source, + prepared_source, + grammar_key, + extractor, + ); } - }; - let extraction = match extractor { - Some(extractor) => { - match document.extract_canonical_artifact(extractor, &report, None) { - Ok(extraction) => Some(extraction), - Err(error) => { - self.record_failure_at(admission_epoch); - return Err(error); + drop(state); + let opened = match grammar_key { + Some(grammar_key) => RetainedParseDocument::open_prepared( + identity, + language_id, + grammar_key, + source, + prepared_source, + self.limits.document, + ), + None => RetainedParseDocument::open( + identity, + language_id, + source, + self.limits.document, + ), + }; + let (document, report) = match opened { + Ok(parsed) => parsed, + Err(error) => { + self.record_failure_at(admission_epoch); + return Err(error); + } + }; + let extraction = match extractor { + Some(extractor) => { + match document.extract_canonical_artifact(extractor, &report, None) { + Ok(extraction) => Some(extraction), + Err(error) => { + self.record_failure_at(admission_epoch); + return Err(error); + } } } + None => None, + }; + let retained_artifact = + extraction.as_ref().map(|parsed| parsed.artifact.clone()); + let current_size = document.retained_source_bytes(); + let entry = Arc::new(Mutex::new(RetainedEntry { + document, + artifact: retained_artifact, + })); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.clear_epoch != admission_epoch { + return Ok((report, extraction)); } - None => None, - }; - let retained_artifact = extraction.as_ref().map(|parsed| parsed.artifact.clone()); - let current_size = document.retained_source_bytes(); - let entry = Arc::new(Mutex::new(RetainedEntry { - document, - artifact: retained_artifact, - })); - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if state.clear_epoch != admission_epoch { - return Ok((report, extraction)); + state.documents.insert(key.clone(), Arc::clone(&entry)); + state.source_bytes.insert(key.clone(), current_size); + touch(&mut state.lru, &key); + evict_to_limits(&mut state, &key, self.limits); + record_success(&mut state.stats, &report, extraction.as_ref()); + state.stats.retained_documents = state.documents.len(); + state.stats.retained_source_bytes = state.source_bytes.values().copied().sum(); + Ok((report, extraction)) } - state.documents.insert(key.clone(), Arc::clone(&entry)); - state.source_bytes.insert(key.clone(), current_size); - touch(&mut state.lru, &key); - evict_to_limits(&mut state, &key, self.limits); - record_success(&mut state.stats, &report, extraction.as_ref()); - state.stats.retained_documents = state.documents.len(); - state.stats.retained_source_bytes = state.source_bytes.values().copied().sum(); - Ok((report, extraction)) } - } + }) } #[allow(clippy::too_many_arguments)] @@ -562,12 +566,18 @@ fn record_success( match extraction.disposition { ParsedExtractionDisposition::FullDocument => { stats.full_extractions = stats.full_extractions.saturating_add(1); + #[cfg(feature = "hotpath")] + hotpath::gauge!("code_index.collect.full_extraction_total").inc(1_u64); } ParsedExtractionDisposition::ChangedRegions => { stats.incremental_extractions = stats.incremental_extractions.saturating_add(1); + #[cfg(feature = "hotpath")] + hotpath::gauge!("code_index.collect.incremental_extraction_total").inc(1_u64); } ParsedExtractionDisposition::Reset { .. } => { stats.reset_extractions = stats.reset_extractions.saturating_add(1); + #[cfg(feature = "hotpath")] + hotpath::gauge!("code_index.collect.reset_extraction_total").inc(1_u64); } } stats.visited_top_level_nodes = stats diff --git a/crates/tracedecay-code-index/src/source_walk.rs b/crates/tracedecay-code-index/src/source_walk.rs index fbec35e1b0..2cc6cbdc23 100644 --- a/crates/tracedecay-code-index/src/source_walk.rs +++ b/crates/tracedecay-code-index/src/source_walk.rs @@ -80,7 +80,7 @@ impl GeneratedDirScope { } } -#[hotpath::measure] +#[hotpath::measure(label = "code_index.capture.source_walk")] pub fn source_walk(project_root: &Path, path_glob: Option<&str>) -> Result { let overrides = build_overrides(project_root, path_glob)?; let has_positive_override = overrides diff --git a/crates/tracedecay-code-index/tests/code_index_suite/ignored_source_admissions.rs b/crates/tracedecay-code-index/tests/code_index_suite/ignored_source_admissions.rs index 8ac3d120a3..e254ed49e6 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/ignored_source_admissions.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/ignored_source_admissions.rs @@ -50,7 +50,7 @@ fn add_present_typescript_file( }); request.captured_files.push(CodeIndexCapturedFileV1 { file_occurrence_id, - sanitized_bytes: bytes, + sanitized_bytes: Arc::from(bytes), sensitivity_level: SensitivityLevelV1::Public, }); request.changed_files.insert(logical_path.to_owned()); diff --git a/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs b/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs index 0d2873d0cd..78ff3227f8 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs @@ -57,7 +57,7 @@ fn import_request() -> CodeIndexBuildRequestV1 { }); request.captured_files.push(CodeIndexCapturedFileV1 { file_occurrence_id: second_occurrence, - sanitized_bytes: second_bytes, + sanitized_bytes: Arc::from(second_bytes), sensitivity_level: SensitivityLevelV1::Public, }); request.snapshot.content_identity = content_digest( 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 a14b932e2c..c673c5863c 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 @@ -21,8 +21,9 @@ use tracedecay_code_index::{ CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, SEALED_GENERATION_FORMAT_REVISION_V1, - VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, - VerifiedSealedLexicalPageV1, sealed_generation_payload_digest, + SharedPhysicalCodeArtifactPoolV1, VerifiedSealedLexicalPageReadV1, + VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, + sealed_generation_payload_digest, }, projection::{ ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, @@ -346,7 +347,7 @@ fn request_at_path( snapshot, captured_files: vec![CodeIndexCapturedFileV1 { file_occurrence_id: file.file_occurrence_id, - sanitized_bytes: source.to_vec(), + sanitized_bytes: Arc::from(source), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }], changed_files: BTreeSet::new(), @@ -378,7 +379,7 @@ pub(super) fn request_with_source( let bytes = source.as_bytes().to_vec(); request.snapshot.files[0].content_digest = content_digest(&bytes); request.snapshot.content_identity = content_digest(&bytes); - request.captured_files[0].sanitized_bytes = bytes; + request.captured_files[0].sanitized_bytes = bytes.into(); request.repository_parse_identity.tree = Some(id::(tree)); request.changed_files.insert("src/lib.rs".to_owned()); request @@ -488,6 +489,115 @@ fn unchanged_increment_does_not_reextract_carried_files() { assert_eq!(after_restored.noop_parses, 0); } +/// The physical reuse pool is an index over immutable generation-owned +/// artifacts, not a second owner of every parsed and chunked payload. Keeping +/// the registry-scoped pool alive after its publication owner shuts down must +/// therefore release the complete file corpus with the generation. +#[test] +fn physical_artifact_pool_does_not_retain_a_dropped_generation() { + let pool = SharedPhysicalCodeArtifactPoolV1::default(); + { + let store = SharedPublicationStore::default(); + let mut owner = CodeIndexProductionOwnerV1::new(config(), store, ApplyingProjectionSink) + .expect("production owner") + .with_physical_artifact_pool(pool.clone()); + let generation = owner + .build_and_publish(request("file.physical.1", 1_100_000), &ActiveControl) + .expect("generation publishes"); + + assert_eq!(pool.stats().resident, 1); + drop(generation); + } + + assert_eq!( + pool.stats().resident, + 0, + "the reuse index must not pin a second copy of a dropped generation" + ); +} + +#[test] +fn physical_artifact_reuse_preserves_byte_exact_sealed_generation() { + let pool = SharedPhysicalCodeArtifactPoolV1::default(); + let mut source_owner = CodeIndexProductionOwnerV1::new( + config(), + SharedPublicationStore::default(), + ApplyingProjectionSink, + ) + .expect("source owner") + .with_physical_artifact_pool(pool.clone()); + let source = source_owner + .build_and_publish(request("file.physical.target", 1_100_000), &ActiveControl) + .expect("source generation publishes"); + + let mut reused_owner = CodeIndexProductionOwnerV1::new( + config(), + SharedPublicationStore::default(), + ApplyingProjectionSink, + ) + .expect("reuse owner") + .with_physical_artifact_pool(pool.clone()); + let reused = reused_owner + .build_and_publish(request("file.physical.target", 1_200_000), &ActiveControl) + .expect("physically reused generation publishes"); + + let mut cold_owner = CodeIndexProductionOwnerV1::new( + config(), + SharedPublicationStore::default(), + ApplyingProjectionSink, + ) + .expect("cold owner"); + let cold = cold_owner + .build_and_publish(request("file.physical.target", 1_200_000), &ActiveControl) + .expect("cold comparison generation publishes"); + + let mut foreign_owner = CodeIndexProductionOwnerV1::new( + config(), + SharedPublicationStore::default(), + ApplyingProjectionSink, + ) + .expect("foreign occurrence owner") + .with_physical_artifact_pool(pool.clone()); + foreign_owner + .build_and_publish(request("file.physical.foreign", 1_200_000), &ActiveControl) + .expect("foreign occurrence generation publishes without unsafe reuse"); + + assert_eq!( + pool.stats().reused, + 1, + "only the byte-exact file occurrence may reuse physical artifacts" + ); + assert_eq!(reused.manifest(), cold.manifest(), "manifest mismatch"); + assert_eq!(reused.snapshot(), cold.snapshot(), "snapshot mismatch"); + assert_eq!(reused.chunks(), cold.chunks(), "chunk mismatch"); + assert_eq!(reused.symbols(), cold.symbols(), "symbol mismatch"); + assert_eq!(reused.lineage(), cold.lineage(), "lineage mismatch"); + assert_eq!(reused.imports(), cold.imports(), "import mismatch"); + assert_eq!(reused.edges(), cold.edges(), "edge mismatch"); + assert_eq!( + reused.edge_abstentions(), + cold.edge_abstentions(), + "edge abstention mismatch" + ); + assert_eq!(reused.coverage(), cold.coverage(), "coverage mismatch"); + assert_eq!( + reused.capability(), + cold.capability(), + "capability mismatch" + ); + assert_eq!( + reused.projection(), + cold.projection(), + "projection mismatch" + ); + assert_eq!( + reused.encode_sealed().expect("reused generation seals"), + cold.encode_sealed().expect("cold generation seals"), + "sharing the physical allocation must preserve every durable byte and digest" + ); + drop(source); +} + /// One file exceeding the bounded per-file parse budget must never fail the /// whole build: the generation still completes, publishes, and serves, with /// the slow file recorded as a typed unsupported document (with a reason) and @@ -544,12 +654,12 @@ fn slow_parse_file_publishes_a_completed_generation_with_a_typed_omission() { captured_files: vec![ CodeIndexCapturedFileV1 { file_occurrence_id: fast.file_occurrence_id.clone(), - sanitized_bytes: fast_source.as_bytes().to_vec(), + sanitized_bytes: Arc::from(fast_source.as_bytes()), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }, CodeIndexCapturedFileV1 { file_occurrence_id: slow.file_occurrence_id.clone(), - sanitized_bytes: slow_source.into_bytes(), + sanitized_bytes: Arc::from(slow_source.into_bytes()), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }, ], @@ -1140,7 +1250,7 @@ fn verified_content_addressed_lexical_source_resumes_from_a_persisted_cursor() { content_digest(format!("{first_source}{second_source}").as_bytes()); request.captured_files.push(CodeIndexCapturedFileV1 { file_occurrence_id: second_file.file_occurrence_id.clone(), - sanitized_bytes: second_source.as_bytes().to_vec(), + sanitized_bytes: Arc::from(second_source.as_bytes()), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }); request.changed_files.clear(); @@ -1523,7 +1633,7 @@ fn verified_sealed_lexical_page_transition_is_canonical_across_importing_files() content_digest(format!("{first_source}{second_source}").as_bytes()); request.captured_files.push(CodeIndexCapturedFileV1 { file_occurrence_id: second_file.file_occurrence_id.clone(), - sanitized_bytes: second_source.as_bytes().to_vec(), + sanitized_bytes: Arc::from(second_source.as_bytes()), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }); request.changed_files.clear(); diff --git a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration/parallel_equivalence.rs b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration/parallel_equivalence.rs index 164c89b7e2..c1da24212f 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration/parallel_equivalence.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration/parallel_equivalence.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::{collections::BTreeSet, sync::Arc}; use tracedecay_code_index::{ chunks::content_digest, @@ -40,7 +40,7 @@ fn multi_file_request(file_count: usize, sealed_at: i64) -> CodeIndexBuildReques }); captured.push(CodeIndexCapturedFileV1 { file_occurrence_id: occurrence, - sanitized_bytes: bytes, + sanitized_bytes: Arc::from(bytes), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }); receipts.push(id::(&format!( diff --git a/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs b/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs index 07c7f84048..35d82f88d6 100644 --- a/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs +++ b/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs @@ -25,6 +25,91 @@ use super::read_model::{ DashboardFreshnessV1, DashboardLegalActionKindV1, DashboardLegalActionRefV1, scope_from_state, }; +/// The durable build phase whose committed boundary the dashboard is reading. +/// +/// A phase is not inferred from scheduler state. The mounted registry publishes +/// the exact phase that owns the active generation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CodeIndexBuildPhaseV1 { + SourceScan, + RelationalPreparation, + BulkCommit, + IndexBuild, + Verification, + Ready, +} + +/// A typed reason an otherwise active generation cannot make durable progress. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CodeIndexBuildBlockedReasonV1 { + ResidentMemory, + SourceUnavailable, + ArtifactStoreUnavailable, + RetryBackoff, +} + +/// The latest committed progress boundary for one active code-index generation. +/// +/// Every count is scoped to `generation_id`. The snapshot never includes a +/// staged page: work is reported only after the batch that owns it commits. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct CodeIndexBuildProgressV1 { + /// Exact generation receiving the committed build work. + pub generation_id: String, + /// Durable daemon-authority epoch that produced this snapshot. + /// + /// This orders snapshots across daemon restarts without relying on wall + /// clock time. + pub daemon_incarnation: u64, + /// Registry-minted scheduler incarnation within one daemon. + /// + /// A worktree retirement/remount creates a new value. `progress_epoch` is + /// comparable only when both incarnation fields match. + pub producer_incarnation: u64, + /// Monotonic publication epoch for replacing delayed progress reads. + pub progress_epoch: u64, + /// Identity of the sealed source whose authenticated bounds define progress. + pub sealed_source_digest: String, + /// Durable pipeline phase that published this snapshot. + pub phase: CodeIndexBuildPhaseV1, + /// Source pages committed to the artifact database. + pub committed_pages: u64, + /// Search chunks committed to the artifact database. + pub committed_chunks: u64, + /// Import evidence rows committed to the artifact database. + pub committed_imports: u64, + /// Payload bytes committed to the artifact database. + pub committed_payload_bytes: u64, + /// Authenticated sealed-source file boundary completed by committed work. + pub completed_files: u64, + /// Authenticated sealed-source file bound for this generation. + pub total_files: u64, + /// Authenticated sealed lexical-byte boundary completed by committed work. + pub completed_lexical_bytes: u64, + /// Authenticated sealed lexical-byte bound for this generation. + pub total_lexical_bytes: u64, + /// Source pages in the batch currently being processed. + pub current_batch_pages: u64, + /// Sealed payload bytes in the batch currently being processed. + pub current_batch_payload_bytes: u64, + /// Monotonic elapsed time for this process's active generation build. + pub elapsed_micros: u64, + /// Duration of the last committed SQLite batch, when one exists. + pub last_commit_latency_micros: Option, + /// Rolling committed-file throughput, absent until it is established. + pub files_per_second: Option, + /// Rolling committed lexical-byte throughput, absent until it is established. + pub lexical_bytes_per_second: Option, + /// Estimated remaining build duration, absent without a truthful rate. + pub estimated_remaining_seconds: Option, + /// Unix-epoch timestamp of the last durable progress publication. + pub last_progress_micros: i64, + /// Reason the active generation cannot currently advance, when known. + pub blocked_reason: Option, +} + /// Freshness/generation state for one mounted worktree. /// /// `Deserialize` is part of the wire contract: the CLI status command decodes @@ -58,6 +143,8 @@ pub struct CodeIndexWorktreeFreshnessV1 { pub hook_hint_count: Option, /// Whether this read covers the complete mounted scheduler state. pub coverage: String, + /// Latest committed progress for the active generation, if one is mounted. + pub progress: Option, } pub type CodeIndexFreshnessReadFuture = @@ -201,6 +288,7 @@ mod tests { staleness_state: Some("fresh".to_owned()), hook_hint_count: Some(0), coverage: "complete".to_owned(), + progress: None, }) }) })); @@ -238,6 +326,7 @@ mod tests { staleness_state: Some("indexing".to_owned()), hook_hint_count: Some(0), coverage: "complete".to_owned(), + progress: None, }) }) })); @@ -248,6 +337,71 @@ mod tests { assert!(!envelope.coverage.is_complete()); } + #[tokio::test] + async fn freshness_route_preserves_committed_generation_progress_exactly() { + let _pin = tracedecay_runtime_core::config::PinnedUserDataDir::new(); + let (_project, mut state) = state_for_test().await; + state.code_index_freshness_reader = Some(Arc::new(|root| { + Box::pin(async move { + Some(CodeIndexWorktreeFreshnessV1 { + worktree_root: root.display().to_string(), + repository_id: Some("repository.fixture".to_owned()), + worktree_id: Some("worktree.fixture".to_owned()), + source_reference: Some("refs/heads/main".to_owned()), + source_revision: Some("commit.fixture".to_owned()), + latest_generation_id: None, + snapshot_content_identity: None, + sealed_at_micros: None, + last_reconcile_micros: Some(42), + staleness_state: Some("indexing".to_owned()), + hook_hint_count: Some(0), + coverage: "complete".to_owned(), + progress: Some(CodeIndexBuildProgressV1 { + generation_id: "generation.catchup.01".to_owned(), + daemon_incarnation: 3, + producer_incarnation: 11, + progress_epoch: 7, + sealed_source_digest: "sha256:sealed-source-catchup".to_owned(), + phase: CodeIndexBuildPhaseV1::BulkCommit, + committed_pages: 16, + committed_chunks: 10_000, + committed_imports: 480, + committed_payload_bytes: 16 * 1024 * 1024, + completed_files: 250, + total_files: 500, + completed_lexical_bytes: 32 * 1024 * 1024, + total_lexical_bytes: 64 * 1024 * 1024, + current_batch_pages: 4, + current_batch_payload_bytes: 4 * 1024 * 1024, + elapsed_micros: 120_000_000, + last_commit_latency_micros: Some(240_000), + files_per_second: Some(250.0), + lexical_bytes_per_second: Some(16.0 * 1024.0 * 1024.0), + estimated_remaining_seconds: Some(120), + last_progress_micros: 43, + blocked_reason: None, + }), + }) + }) + })); + + let Json(envelope) = freshness(State(state)).await; + + let progress = envelope.payload.worktrees[0] + .progress + .as_ref() + .expect("mounted build progress"); + assert_eq!(progress.generation_id, "generation.catchup.01"); + assert_eq!(progress.progress_epoch, 7); + assert_eq!(progress.phase, CodeIndexBuildPhaseV1::BulkCommit); + assert_eq!(progress.completed_files, 250); + assert_eq!(progress.total_files, 500); + assert_eq!(progress.completed_lexical_bytes, 32 * 1024 * 1024); + assert_eq!(progress.total_lexical_bytes, 64 * 1024 * 1024); + assert_eq!(progress.files_per_second, Some(250.0)); + assert_eq!(progress.estimated_remaining_seconds, Some(120)); + } + #[tokio::test] async fn attached_registry_without_a_mount_is_unknown_not_unsupported() { let _pin = tracedecay_runtime_core::config::PinnedUserDataDir::new(); diff --git a/crates/tracedecay-graph-db/src/generation.rs b/crates/tracedecay-graph-db/src/generation.rs index 2d7248a6ef..27b91098f0 100644 --- a/crates/tracedecay-graph-db/src/generation.rs +++ b/crates/tracedecay-graph-db/src/generation.rs @@ -23,6 +23,7 @@ use crate::{ }; const DIGEST_CHECK_INTERVAL_BYTES: u64 = 64 * 1024; +const CHECKED_VEC_INITIAL_CAPACITY_BYTES: usize = 1_024; #[path = "generation/identity.rs"] mod identity; @@ -937,6 +938,8 @@ struct CheckedVecWriter<'a> { max_bytes: usize, check: &'a dyn Fn() -> Result<(), GraphDbError>, failure: Option, + #[cfg(test)] + allocation_growths: usize, } impl<'a> CheckedVecWriter<'a> { @@ -951,6 +954,8 @@ impl<'a> CheckedVecWriter<'a> { max_bytes, check, failure: None, + #[cfg(test)] + allocation_growths: 0, }) } @@ -961,6 +966,11 @@ impl<'a> CheckedVecWriter<'a> { (self.check)()?; Ok(self.bytes) } + + #[cfg(test)] + fn allocation_growths(&self) -> usize { + self.allocation_growths + } } impl Write for CheckedVecWriter<'_> { @@ -975,14 +985,38 @@ impl Write for CheckedVecWriter<'_> { "canonical graph replay exceeds its payload bound", )); } - if self.bytes.try_reserve_exact(bytes.len()).is_err() { - self.failure = Some(GraphDbError::budget_exhausted_count( - GraphBudgetKind::Write, - self.max_bytes, - )); - return Err(io::Error::other( - "canonical graph replay allocation exceeds its product budget", - )); + if next_len > self.bytes.capacity() { + let growth_target = if self.bytes.capacity() == 0 { + CHECKED_VEC_INITIAL_CAPACITY_BYTES + } else { + self.bytes + .capacity() + .checked_mul(2) + .unwrap_or(self.max_bytes) + }; + let target_capacity = growth_target.max(next_len).min(self.max_bytes); + let additional = target_capacity + .checked_sub(self.bytes.len()) + .ok_or_else(|| { + io::Error::other("canonical graph replay capacity is below its encoded length") + })?; + #[cfg(test)] + let capacity_before_reserve = self.bytes.capacity(); + if self.bytes.try_reserve_exact(additional).is_err() + || self.bytes.capacity() > self.max_bytes + { + self.failure = Some(GraphDbError::budget_exhausted_count( + GraphBudgetKind::Write, + self.max_bytes, + )); + return Err(io::Error::other( + "canonical graph replay allocation exceeds its product budget", + )); + } + #[cfg(test)] + if self.bytes.capacity() != capacity_before_reserve { + self.allocation_growths += 1; + } } let length = u64::try_from(bytes.len()) .map_err(|_| io::Error::other("canonical graph replay chunk is too large"))?; @@ -1022,3 +1056,65 @@ fn checked_canonical_bytes( .map_err(|error| GraphDbError::invalid(format!("failed to encode {subject}: {error}")))?; Ok(bytes) } + +#[cfg(test)] +mod checked_vec_writer_tests { + use sha2::{Digest, Sha256}; + use tracedecay_domain::canonical_text::encode_lowercase_hex; + + use super::{CheckedVecWriter, GraphDbError, checked_canonical_bytes}; + + #[test] + fn many_tiny_serde_writes_use_bounded_amortized_growth() { + let value = vec![0_u8; 4_096]; + let mut writer = + CheckedVecWriter::new(&|| Ok(()), 16 * 1_024).expect("bounded writer initializes"); + + serde_json::to_writer(&mut writer, &value).expect("fixture fits the writer bound"); + let allocation_growths = writer.allocation_growths(); + let actual = writer.finish().expect("bounded writer finishes"); + + assert_eq!( + actual, + serde_json::to_vec(&value).expect("fixture serializes") + ); + assert_eq!(actual.len(), 8_193); + assert_eq!( + encode_lowercase_hex(&Sha256::digest(&actual)), + "cb113f74dc19a08fcacd246b84ca69e1dff17209792ea3bd8d1b34397f5eca92" + ); + assert!( + allocation_growths <= 16, + "4,096 tiny values caused {allocation_growths} allocation growths" + ); + } + + #[test] + fn canonical_bytes_refuse_the_first_byte_past_the_bound() { + let value = vec![0_u8; 4_096]; + + let error = checked_canonical_bytes(&value, &|| Ok(()), "bounded fixture", 8_192) + .expect_err("8,193 encoded bytes must exceed the bound"); + + assert!(matches!( + error, + GraphDbError::InvalidRequest { message } + if message.contains("canonical graph replay exceeds its payload bound") + )); + } + + #[test] + fn writer_capacity_never_exceeds_its_payload_bound() { + let value = vec![0_u8; 4_096]; + let max_bytes = 8_192; + let mut writer = + CheckedVecWriter::new(&|| Ok(()), max_bytes).expect("bounded writer initializes"); + + let error = serde_json::to_writer(&mut writer, &value) + .expect_err("the final encoded byte must be refused"); + + assert!(error.to_string().contains("canonical graph replay exceeds")); + assert!(writer.bytes.len() <= max_bytes); + assert!(writer.bytes.capacity() <= max_bytes); + } +} diff --git a/crates/tracedecay-query/Cargo.toml b/crates/tracedecay-query/Cargo.toml index 8efb2a9767..5cfa13d71f 100644 --- a/crates/tracedecay-query/Cargo.toml +++ b/crates/tracedecay-query/Cargo.toml @@ -13,8 +13,9 @@ fst = { version = "=0.4.7", default-features = false, features = ["levenshtein"] hex = "0.4" hmac = "0.13.0" hotpath.workspace = true +rayon = "1" roaring = { version = "=0.11.4", default-features = false } -rusqlite.workspace = true +rusqlite = { workspace = true, features = ["functions"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" @@ -75,3 +76,8 @@ tempfile = "3" tracedecay-code-extraction = { path = "../tracedecay-code-extraction", features = ["lite"] } tracedecay-code-index = { path = "../tracedecay-code-index", default-features = false, features = ["lite", "test-helpers"] } tracedecay-graph-db = { path = "../tracedecay-graph-db", version = "0.1.0", features = ["test-helpers"] } +tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0" } + +[[bench]] +name = "code_lexical_catchup" +harness = false diff --git a/crates/tracedecay-query/benches/code_lexical_catchup.rs b/crates/tracedecay-query/benches/code_lexical_catchup.rs new file mode 100644 index 0000000000..14bed00d0d --- /dev/null +++ b/crates/tracedecay-query/benches/code_lexical_catchup.rs @@ -0,0 +1,471 @@ +//! Deterministic, operator-data-free comparison of lexical artifact ingestion +//! transaction shapes. This target intentionally owns its fixture because +//! query's production fixture helpers are test-private. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::io::Cursor; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use serde::Serialize; +use tracedecay_code_index::chunks::content_digest; +use tracedecay_code_index::production::{ + CodeIndexAtomicPublicationPort, CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, + CodeIndexExecutionControlV1, CodeIndexGenerationScopeV1, CodeIndexProductionConfigV1, + CodeIndexProductionOwnerV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, + CodeIndexRepositoryParseIdentityV1, VerifiedSealedLexicalPageReadV1, + VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, + VerifiedSealedLexicalSourceReceiptV1, +}; +use tracedecay_code_index::projection::{ + ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, + ProjectionSinkErrorV1, ProjectionSinkReceiptV1, +}; +use tracedecay_domain::{ + ChunkerRevision, CodeGenerationId, ComponentRevision, FileOccurrenceId, + FreshnessCompatibilityV1, ManifestDigest, PolicyRevisionId, PrivacyDomainId, ProjectId, + ProjectionBatchRequestV1, ProjectionKeyV1, ProjectionKindV1, ProjectionOperationV1, + ProjectionOutcomeV1, RepositoryDirtyStateV1, RepositoryId, SanitizationReceiptId, + SanitizedCodeFileV1, SanitizedCodeSnapshotV1, SanitizerRevision, ScoreDomainId, + SensitivityLevelV1, SnapshotFileDispositionV1, SourceFreshness, SourceInstanceKey, + SourceNamespace, UtcMicros, +}; +use tracedecay_query::retrieval::lexical::{ + CodeLexicalArtifactBuilderV1, CodeLexicalArtifactFinalizationStepV1, + CodeLexicalProjectionMetadataV1, VerifiedCodeLexicalArtifactV1, +}; + +const FIXTURE_FILE_COUNT: usize = 48; +const BATCH_PAGE_LIMIT: usize = 16; + +struct ActiveControl; + +impl CodeIndexExecutionControlV1 for ActiveControl { + fn is_cancelled(&self) -> bool { + false + } + + fn is_deadline_exceeded(&self) -> bool { + false + } +} + +#[derive(Default)] +struct PublicationStore { + active: Arc>>>, +} + +impl CodeIndexAtomicPublicationPort for PublicationStore { + fn load_active( + &self, + scope: &CodeIndexGenerationScopeV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + Ok(self + .active + .lock() + .expect("benchmark publication lock") + .get(scope) + .map(|generation| generation.as_ref().clone())) + } + + fn publish_atomically( + &mut self, + scope: &CodeIndexGenerationScopeV1, + expected_active_generation: Option<&CodeGenerationId>, + generation: Arc, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + let mut active = self.active.lock().expect("benchmark publication lock"); + if active + .get(scope) + .map(|current| current.manifest().generation_id.clone()) + .as_ref() + != expected_active_generation + { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + active.insert(scope.clone(), generation); + Ok(()) + } +} + +struct ProjectionSink; + +impl CodeChunkProjectionSink for ProjectionSink { + fn project_changed_chunks( + &mut self, + request: &ProjectionBatchRequestV1, + receipt_builder: ProjectionReceiptBuilderV1<'_>, + ) -> Result { + let mut decisions = request + .changes + .added_or_changed + .iter() + .map(|change| ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: change.current_digest.clone(), + operation: if change.prior_digest.is_some() { + ProjectionOperationV1::Updated + } else { + ProjectionOperationV1::Added + }, + outcome: ProjectionOutcomeV1::Applied, + output_digest: change.current_digest.clone(), + }) + .collect::>(); + decisions.extend( + request + .changes + .deleted + .iter() + .map(|change| ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: None, + operation: ProjectionOperationV1::Deleted, + outcome: ProjectionOutcomeV1::Applied, + output_digest: None, + }), + ); + decisions.extend( + request + .changes + .reused + .iter() + .map(|change| ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: change.current_digest.clone(), + operation: ProjectionOperationV1::Reused, + outcome: ProjectionOutcomeV1::Reused, + output_digest: None, + }), + ); + receipt_builder + .build(&decisions) + .map_err(|error| ProjectionSinkErrorV1::Rejected(error.to_string())) + } +} + +struct Fixture { + metadata: CodeLexicalProjectionMetadataV1, + pages: Vec, + source_receipt: VerifiedSealedLexicalSourceReceiptV1, +} + +#[derive(Clone, Copy)] +enum IngestionMode { + OnePage, + BoundedBatch, +} + +impl IngestionMode { + const fn name(self) -> &'static str { + match self { + Self::OnePage => "one_page", + Self::BoundedBatch => "bounded_batch", + } + } +} + +#[derive(Serialize)] +struct RunReport { + mode: &'static str, + ingest_wall_ns: u64, + end_to_end_wall_ns: u64, + committed_pages: u64, + committed_chunks: u64, + committed_payload_bytes: u64, + sqlite_ingestion_commits: u64, + artifact_bytes: u64, + artifact_digest: String, + source_cumulative_digest: String, +} + +struct RunResult { + report: RunReport, + receipt: VerifiedCodeLexicalArtifactV1, +} + +#[derive(Serialize)] +struct ComparisonReport { + fixture_pages: usize, + batch_page_limit: usize, + one_page: RunReport, + bounded_batch: RunReport, + final_receipt_equal: bool, + artifact_digest_equal: bool, + source_cumulative_digest_equal: bool, +} + +fn main() { + let fixture = build_fixture(); + let one_page = run(&fixture, IngestionMode::OnePage); + let bounded_batch = run(&fixture, IngestionMode::BoundedBatch); + + let final_receipt_equal = one_page.receipt == bounded_batch.receipt; + let artifact_digest_equal = + one_page.receipt.artifact_digest() == bounded_batch.receipt.artifact_digest(); + let source_cumulative_digest_equal = one_page.receipt.source_cumulative_digest() + == bounded_batch.receipt.source_cumulative_digest(); + let report = ComparisonReport { + fixture_pages: fixture.pages.len(), + batch_page_limit: BATCH_PAGE_LIMIT, + one_page: one_page.report, + bounded_batch: bounded_batch.report, + final_receipt_equal, + artifact_digest_equal, + source_cumulative_digest_equal, + }; + + println!( + "{}", + serde_json::to_string_pretty(&report).expect("serialize benchmark report") + ); + assert!( + final_receipt_equal && artifact_digest_equal && source_cumulative_digest_equal, + "the compared ingestion paths must produce the exact same final receipt and digests" + ); +} + +fn build_fixture() -> Fixture { + let repository = id::("repository.catchup-benchmark"); + let sanitizer_revision = id::("sanitizer.catchup-benchmark.v1"); + let sources = (0..FIXTURE_FILE_COUNT) + .map(|ordinal| { + let file_id = format!("file.catchup.{ordinal:03}"); + let logical_path = format!("src/catchup_{ordinal:03}.ts"); + let source = format!( + "import type {{ Widget }} from \"widget-kit\";\nexport function render_{ordinal:03}(value: Widget) {{ return value; }}\n" + ) + .into_bytes(); + let file = SanitizedCodeFileV1 { + file_occurrence_id: id::(&file_id), + logical_path, + language: Some(id("typescript")), + content_digest: content_digest(&source), + disposition: SnapshotFileDispositionV1::Present, + }; + (file, source) + }) + .collect::>(); + let snapshot = SanitizedCodeSnapshotV1 { + repository: repository.clone(), + worktree: None, + reference: None, + source_revision: None, + sanitizer_revision: sanitizer_revision.clone(), + sanitization_receipts: vec![id::("receipt.catchup-benchmark")], + content_identity: content_digest(&sources[0].1), + captured_at: UtcMicros(1_000_000), + files: sources.iter().map(|(file, _)| file.clone()).collect(), + }; + let request = CodeIndexBuildRequestV1 { + snapshot, + captured_files: sources + .iter() + .map(|(file, source)| CodeIndexCapturedFileV1 { + file_occurrence_id: file.file_occurrence_id.clone(), + sanitized_bytes: Arc::from(source.clone()), + sensitivity_level: SensitivityLevelV1::Public, + }) + .collect(), + changed_files: sources + .iter() + .map(|(file, _)| file.logical_path.clone()) + .collect::>(), + invalidations: BTreeSet::new(), + ignored_source_admissions: Vec::new(), + repository_parse_identity: CodeIndexRepositoryParseIdentityV1 { + tree: None, + dirty: RepositoryDirtyStateV1::Dirty, + }, + sealed_at: UtcMicros(1_100_000), + target_projection_key: ProjectionKeyV1 { + kind: ProjectionKindV1::Lexical, + schema_revision: "lexical.v1".to_owned(), + profile_digest: digest_id('e'), + }, + }; + let config = CodeIndexProductionConfigV1 { + project_id: id::("project.catchup-benchmark"), + repository: repository.clone(), + sanitizer_revision, + policy_revision: id::("policy.catchup-benchmark.v1"), + chunker_revision: id::("chunker.catchup-benchmark.v1"), + privacy_domain: id::("privacy.catchup-benchmark"), + privacy_key_epoch: 1, + max_snapshot_age_micros: None, + }; + let control = ActiveControl; + let mut owner = + CodeIndexProductionOwnerV1::new(config, PublicationStore::default(), ProjectionSink) + .expect("build deterministic production fixture owner"); + let generation = owner + .build_and_publish(request, &control) + .expect("build deterministic sealed generation"); + let sealed = generation + .encode_sealed() + .expect("encode deterministic sealed generation"); + let sealed_len = u64::try_from(sealed.len()).expect("sealed generation length"); + let envelope: serde_json::Value = + serde_json::from_slice(&sealed).expect("decode sealed generation envelope"); + let state_digest = id::( + envelope["state_digest"] + .as_str() + .expect("sealed generation state digest"), + ); + let metadata = CodeLexicalProjectionMetadataV1 { + generation: generation.manifest().generation_id.clone(), + repository_id: Some(repository), + logical_paths: generation + .snapshot() + .files + .iter() + .map(|file| (file.file_occurrence_id.clone(), file.logical_path.clone())) + .collect(), + freshness: freshness(), + exact_retriever_revision: id::("retriever.exact.catchup-benchmark.v1"), + lexical_retriever_revision: id::( + "retriever.lexical.catchup-benchmark.v1", + ), + exact_score_domain: id::("score.exact.catchup-benchmark.v1"), + }; + let mut source = VerifiedSealedLexicalPageSourceV1::open( + Cursor::new(sealed), + sealed_len, + state_digest, + 1, + 1024 * 1024, + &control, + ) + .expect("open verified lexical page source"); + let mut pages = Vec::new(); + let source_receipt = loop { + match source + .next_page(&control) + .expect("read verified lexical page") + { + VerifiedSealedLexicalPageReadV1::Page(page) => pages.push(page), + VerifiedSealedLexicalPageReadV1::Complete(receipt) => break receipt, + } + }; + assert!( + pages.len() > BATCH_PAGE_LIMIT, + "fixture must provide more pages than one bounded batch" + ); + Fixture { + metadata, + pages, + source_receipt, + } +} + +fn run(fixture: &Fixture, mode: IngestionMode) -> RunResult { + let directory = tempfile::tempdir().expect("create benchmark artifact directory"); + let artifact_path = directory.path().join(format!("{}.sqlite", mode.name())); + let control = ActiveControl; + let mut builder = + CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata.clone()) + .expect("create isolated benchmark artifact"); + let started = Instant::now(); + let (progress, sqlite_ingestion_commits) = match mode { + IngestionMode::OnePage => { + let mut progress = builder.progress().expect("read initial artifact progress"); + for page in &fixture.pages { + progress = builder + .append_page(page, &control) + .expect("append deterministic benchmark page"); + } + ( + progress, + u64::try_from(fixture.pages.len()).expect("page count fits u64"), + ) + } + IngestionMode::BoundedBatch => { + let mut progress = builder.progress().expect("read initial artifact progress"); + let mut commits = 0_u64; + for pages in fixture.pages.chunks(BATCH_PAGE_LIMIT) { + progress = builder + .append_pages(pages, &control) + .expect("append deterministic benchmark page batch"); + commits = commits.checked_add(1).expect("commit count fits u64"); + } + (progress, commits) + } + }; + let ingest_wall_ns = elapsed_ns(started.elapsed()); + let receipt = finalize(&mut builder, &fixture.source_receipt, &control); + let end_to_end_wall_ns = elapsed_ns(started.elapsed()); + assert_eq!( + progress.next_page_ordinal, + u64::try_from(fixture.pages.len()).expect("page count fits u64"), + "ingestion must durably commit every verified fixture page" + ); + RunResult { + report: RunReport { + mode: mode.name(), + ingest_wall_ns, + end_to_end_wall_ns, + committed_pages: progress.next_page_ordinal, + committed_chunks: progress.completed_chunks, + committed_payload_bytes: progress.completed_payload_bytes, + sqlite_ingestion_commits, + artifact_bytes: receipt.file_size_bytes(), + artifact_digest: receipt.artifact_digest().as_str().to_owned(), + source_cumulative_digest: receipt.source_cumulative_digest().as_str().to_owned(), + }, + receipt, + } +} + +fn finalize( + builder: &mut CodeLexicalArtifactBuilderV1, + source_receipt: &VerifiedSealedLexicalSourceReceiptV1, + control: &dyn CodeIndexExecutionControlV1, +) -> VerifiedCodeLexicalArtifactV1 { + loop { + match builder + .advance_finalization(source_receipt, 4_096, control) + .expect("finalize deterministic benchmark artifact") + { + CodeLexicalArtifactFinalizationStepV1::Pending { .. } => {} + CodeLexicalArtifactFinalizationStepV1::Ready(receipt) => return *receipt, + } + } +} + +fn freshness() -> SourceFreshness { + SourceFreshness { + source_namespace: id::("ns.code.catchup-benchmark"), + source_instance: id::("instance.catchup-benchmark"), + source_watermark: Some(7), + projection_watermark: Some(7), + observed_at: UtcMicros(7), + source_generation: Some(1), + generation_lag: Some(0), + compatibility: FreshnessCompatibilityV1::Current, + policy_revision: id("policy.catchup-benchmark.v1"), + } +} + +fn id(value: &str) -> T +where + T: TryFrom, + >::Error: fmt::Debug, +{ + T::try_from(value.to_owned()).expect("valid deterministic benchmark identity") +} + +fn digest_id(byte: char) -> T +where + T: TryFrom, + >::Error: fmt::Debug, +{ + id(&format!("sha256:{}", byte.to_string().repeat(64))) +} + +fn elapsed_ns(duration: std::time::Duration) -> u64 { + u64::try_from(duration.as_nanos()).expect("benchmark wall time fits u64") +} diff --git a/crates/tracedecay-query/src/retrieval/lexical.rs b/crates/tracedecay-query/src/retrieval/lexical.rs index a08ccf5cef..6dea5b362e 100644 --- a/crates/tracedecay-query/src/retrieval/lexical.rs +++ b/crates/tracedecay-query/src/retrieval/lexical.rs @@ -29,12 +29,14 @@ pub use self::projection::{ CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeExactLexicalArtifactReaderV1, - CodeExactProjectionAdapterV1, CodeLexicalArtifactBuildProgressV1, CodeLexicalArtifactBuilderV1, - CodeLexicalArtifactErrorV1, CodeLexicalArtifactFinalizationStepV1, + CodeExactProjectionAdapterV1, CodeLexicalArtifactBatchLimitV1, + CodeLexicalArtifactBuildProgressV1, CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, + CodeLexicalArtifactFinalizationPhaseV1, CodeLexicalArtifactFinalizationStepV1, CodeLexicalArtifactOccurrenceV1, CodeLexicalArtifactReaderV1, CodeLexicalArtifactSectionDigestV1, CodeLexicalImportMembershipWitnessV1, CodeLexicalProjectionAdapterV1, CodeLexicalProjectionBuildStepV1, CodeLexicalProjectionBuildV1, CodeLexicalProjectionMetadataV1, LEXICAL_PROJECTION_BUILD_DEADLINE_MICROS_V1, + PreparedCodeLexicalArtifactBatchV1, PreparedCodeLexicalArtifactPageV1, VerifiedCodeLexicalArtifactV1, lexical_projection_build_deadline_micros, }; diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection.rs b/crates/tracedecay-query/src/retrieval/lexical/projection.rs index f338a74872..a3d2377c10 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection.rs @@ -31,10 +31,13 @@ pub use artifact::{ CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeExactLexicalArtifactReaderV1, - CodeLexicalArtifactBuildProgressV1, CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, - CodeLexicalArtifactFinalizationStepV1, CodeLexicalArtifactOccurrenceV1, - CodeLexicalArtifactReaderV1, CodeLexicalArtifactSectionDigestV1, - CodeLexicalImportMembershipWitnessV1, VerifiedCodeLexicalArtifactV1, + CodeLexicalArtifactBatchLimitV1, CodeLexicalArtifactBuildProgressV1, + CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, + CodeLexicalArtifactFinalizationPhaseV1, CodeLexicalArtifactFinalizationStepV1, + CodeLexicalArtifactOccurrenceV1, CodeLexicalArtifactReaderV1, + CodeLexicalArtifactSectionDigestV1, CodeLexicalImportMembershipWitnessV1, + PreparedCodeLexicalArtifactBatchV1, PreparedCodeLexicalArtifactPageV1, + VerifiedCodeLexicalArtifactV1, }; use postings::{ByteNgramBudget, ByteNgramPostings, FuzzyTermIndex}; @@ -129,6 +132,9 @@ struct ProjectedChunkV1 { exact_terms: Vec, sanitized_text: BoundedSanitizedText, logical_path: String, + symbol_simple_name: Option, + symbol_qualified_name: Option, + symbol_kind: Option, field_lengths: BTreeMap, normalized_text: String, } @@ -147,6 +153,9 @@ impl ProjectedChunkV1 { chunk.exact_terms, chunk.sanitized_text, logical_path, + None, + None, + None, normalized_text, fields, ) @@ -158,6 +167,7 @@ impl ProjectedChunkV1 { fn from_ref( chunk: &CodeSearchChunkV1, logical_path: String, + display: Option<&tracedecay_code_index::production::VerifiedSealedLexicalSymbolDisplayV1>, ) -> (Self, BTreeMap>) { let fields = Self::projected_fields(chunk, &logical_path); let normalized_text = normalize_lexical(chunk.sanitized_text.as_str()); @@ -168,6 +178,9 @@ impl ProjectedChunkV1 { chunk.exact_terms.clone(), chunk.sanitized_text.clone(), logical_path, + display.map(|display| display.simple_name().to_owned()), + display.map(|display| display.qualified_name().to_owned()), + display.map(|display| display.kind().to_owned()), normalized_text, fields, ) @@ -241,6 +254,9 @@ impl ProjectedChunkV1 { exact_terms: Vec, sanitized_text: BoundedSanitizedText, logical_path: String, + symbol_simple_name: Option, + symbol_qualified_name: Option, + symbol_kind: Option, normalized_text: String, fields: BTreeMap>, ) -> (Self, BTreeMap>) { @@ -256,6 +272,9 @@ impl ProjectedChunkV1 { exact_terms, sanitized_text, logical_path, + symbol_simple_name, + symbol_qualified_name, + symbol_kind, field_lengths, normalized_text, }, diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs index fbbaafd60a..3e087484c6 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs @@ -12,16 +12,19 @@ use tracedecay_code_index::production::{CodeIndexExecutionControlV1, CodeIndexIn mod builder; mod format; mod postings; +mod prepared; mod reader; pub use builder::{ CodeLexicalArtifactBuildProgressV1, CodeLexicalArtifactBuilderV1, - CodeLexicalArtifactFinalizationStepV1, + CodeLexicalArtifactFinalizationPhaseV1, CodeLexicalArtifactFinalizationStepV1, + PreparedCodeLexicalArtifactBatchV1, }; pub use format::{ CodeLexicalArtifactOccurrenceV1, CodeLexicalArtifactSectionDigestV1, CodeLexicalImportMembershipWitnessV1, VerifiedCodeLexicalArtifactV1, }; +pub use prepared::PreparedCodeLexicalArtifactPageV1; pub use reader::{CodeExactLexicalArtifactReaderV1, CodeLexicalArtifactReaderV1}; /// Default and maximum budget for the artifact build memory ledger. @@ -29,18 +32,16 @@ pub use reader::{CodeExactLexicalArtifactReaderV1, CodeLexicalArtifactReaderV1}; /// This is a *ledger claim over tracked allocations*, not a hard RSS bound. /// The enforced ledger charges, as if simultaneous: the SQLite page-cache /// authority granted to the staging connection, the builder-retained -/// projection metadata (identity and logical-path capacities), the sealed -/// page's retained owned bytes, and a conservative arithmetic per-chunk/ -/// per-import transient upper bound — the cloned chunk, projected row, -/// field/token vectors and frequency map, serialization buffers, and -/// n-gram scratch. A page whose charge exceeds the budget is refused before -/// any preflight allocation, staging mutation, or source advance. +/// projection metadata (identity and logical-path capacities), every sealed +/// page retained by an admitted batch, every prepared relational value, and +/// the widest in-flight per-record preparation scratch. A batch whose charge +/// exceeds the budget is refused before SQLite mutation or source advance. /// /// Explicitly outside the claim (the narrowed part): SQLite's `cache_size` /// is a target the engine may transiently exceed, per-statement and /// allocator metadata overhead are unaccounted, and `temp_store = FILE` /// keeps temporary b-trees on disk rather than bounding them in memory. -pub const CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1: usize = 256 * 1024 * 1024; +pub const CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1: usize = 1536 * 1024 * 1024; /// Maximum reader cache budget: the stored metadata copy plus the SQLite /// page-cache grant, which stays inside the kernel SQLite window ([2, 64] /// MiB page cache, mmap disabled). The reader's retained claim is the @@ -49,6 +50,15 @@ pub const CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1: usize = 256 * 1024 /// target, not a hard allocator bound. pub const CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1: usize = 256 * 1024 * 1024; pub const CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1: usize = 96 * 1024 * 1024; +pub const CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1: usize = 2_000_000; +pub const CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1: usize = 256 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CodeLexicalArtifactBatchLimitV1 { + Memory, + PreparedRows, + EstimatedWriteBytes, +} /// Page-cache authority granted to artifact connections; charged in full /// against the memory ledgers because SQLite may use all of it. Sized to @@ -59,6 +69,12 @@ const ARTIFACT_SQLITE_CACHE_BYTES: usize = 64 * 1024 * 1024; /// The kernel SQLite window's page-cache floor. const ARTIFACT_SQLITE_CACHE_FLOOR_BYTES: usize = 2 * 1024 * 1024; const ARTIFACT_DOCUMENT_SCRATCH_LIMIT_BYTES: usize = 64 * 1024 * 1024; +/// Conservative live charge while one page's n-grams move from the ordered +/// key map and Roaring containers into canonical encoded shards. One logical +/// membership pays for a worst-case distinct B-tree entry/container plus the +/// sparse document value; the separately retained shard bytes cover encoded +/// output that overlaps the shrinking map. +const NGRAM_AGGREGATION_BYTES_PER_LOGICAL_POSTING_V1: usize = 160; #[derive(Debug, Error)] pub enum CodeLexicalArtifactErrorV1 { @@ -72,6 +88,14 @@ pub enum CodeLexicalArtifactErrorV1 { Missing(String), #[error("lexical artifact reservation is unavailable: {0}")] Unreserved(String), + #[error( + "lexical artifact page batch exceeds its {limit:?} bound: needs {required}, maximum {maximum}" + )] + BatchTooLarge { + limit: CodeLexicalArtifactBatchLimitV1, + required: usize, + maximum: usize, + }, #[error("lexical artifact operation was interrupted: {0:?}")] Interrupted(CodeIndexInterruptionV1), #[error("lexical artifact contract violation: {0}")] @@ -111,6 +135,14 @@ fn sqlite_corrupt(error: rusqlite::Error) -> CodeLexicalArtifactErrorV1 { /// `journal_mode = DELETE`: a sealed artifact is one content-addressed file, /// and a WAL sidecar would fall outside its digest; bounded finalization /// persists its own verified progress, so rollback-journal durability suffices. +/// SQLite's auxiliary sorter width reuses the canonical code-index worker +/// authority: the connection thread occupies one admitted worker and SQLite +/// may use only the remainder. `temp_store = FILE` keeps corpus-wide CREATE +/// INDEX runs disk-backed; their allocator/statement overhead remains outside +/// this module's narrowed memory-ledger claim. The modeled-reservation gauge +/// reports the caller plus effective helpers at the canonical 128 MiB worker +/// charge; it is a subset of the scheduler's existing admission, not another +/// cache or a second memory authority. fn open_builder_connection( path: &Path, ) -> Result { @@ -132,12 +164,67 @@ fn open_builder_connection( connection .pragma_update(None, "cache_size", cache_kib) .map_err(sqlite_error)?; + let requested_sorter_workers = + tracedecay_code_index::parallelism::indexing_workers().saturating_sub(1); + let requested_sorter_workers_i64 = i64::try_from(requested_sorter_workers) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + connection + .pragma_update(None, "threads", requested_sorter_workers_i64) + .map_err(sqlite_error)?; + let effective_sorter_workers: i64 = connection + .pragma_query_value(None, "threads", |row| row.get(0)) + .map_err(sqlite_error)?; + let effective_sorter_workers = usize::try_from(effective_sorter_workers).map_err(|_| { + CodeLexicalArtifactErrorV1::Contract( + "SQLite returned a negative lexical sorter worker limit".to_owned(), + ) + })?; + if effective_sorter_workers > requested_sorter_workers { + return Err(CodeLexicalArtifactErrorV1::Contract(format!( + "SQLite granted {effective_sorter_workers} lexical sorter workers above the canonical {requested_sorter_workers} auxiliary-worker bound" + ))); + } + hotpath::gauge!("query.artifact.sqlite_sorter_workers.requested").set(requested_sorter_workers); + hotpath::gauge!("query.artifact.sqlite_sorter_workers.effective").set(effective_sorter_workers); + hotpath::gauge!("query.artifact.sqlite_sorter.modeled_reservation_bytes").set( + tracedecay_code_index::parallelism::worker_reservation_bytes( + effective_sorter_workers.saturating_add(1), + ), + ); + hotpath::gauge!("query.artifact.sqlite_sorter.temp_store_file").set(1u64); Ok(connection) } +fn with_builder_sorter_cpu_admission( + connection: &rusqlite::Connection, + operation: impl FnOnce() -> T, +) -> Result { + let effective_sorter_workers: i64 = connection + .pragma_query_value(None, "threads", |row| row.get(0)) + .map_err(sqlite_error)?; + let admitted_units = usize::try_from(effective_sorter_workers) + .map_err(|_| { + CodeLexicalArtifactErrorV1::Contract( + "SQLite returned a negative lexical sorter worker limit".to_owned(), + ) + })? + .checked_add(1) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical sorter CPU admission width overflowed".to_owned(), + ) + })?; + hotpath::gauge!("query.artifact.sqlite_sorter.admitted_cpu_units").set(admitted_units); + Ok(tracedecay_code_index::parallelism::with_background_cpu_permits(admitted_units, operation)) +} + #[cfg(test)] mod tests { - use super::{ARTIFACT_SQLITE_CACHE_BYTES, open_builder_connection}; + use std::num::NonZeroUsize; + + use super::{ + ARTIFACT_SQLITE_CACHE_BYTES, open_builder_connection, with_builder_sorter_cpu_admission, + }; /// Artifact connections stay inside the kernel SQLite window: no mmap /// grant, page cache at most 64 MiB, and `synchronous = NORMAL` — never @@ -173,5 +260,73 @@ mod tests { synchronous, 1, "artifact staging must use synchronous=NORMAL" ); + let temp_store: i64 = connection + .pragma_query_value(None, "temp_store", |row| row.get(0)) + .expect("temp-store pragma"); + assert_eq!( + temp_store, 1, + "SQLite sorter PMAs must spill to files rather than retaining the corpus in memory" + ); + } + + #[test] + fn builder_connections_reuse_canonical_worker_width_for_sqlite_sorters() { + let directory = tempfile::tempdir().expect("artifact tempdir"); + let connection = open_builder_connection(&directory.path().join("workers.sqlite")) + .expect("builder connection"); + let capability_probe = + rusqlite::Connection::open_in_memory().expect("open SQLite worker capability probe"); + capability_probe + .pragma_update(None, "threads", i64::MAX) + .expect("probe SQLite worker ceiling"); + let sqlite_worker_ceiling: i64 = capability_probe + .pragma_query_value(None, "threads", |row| row.get(0)) + .expect("read SQLite worker ceiling"); + let admitted_auxiliary_threads = tracedecay_code_index::parallelism::indexing_workers() + .saturating_sub(1) + .min( + usize::try_from(sqlite_worker_ceiling).expect("nonnegative SQLite worker ceiling"), + ); + let configured_threads: i64 = connection + .pragma_query_value(None, "threads", |row| row.get(0)) + .expect("read artifact SQLite worker limit"); + assert_eq!( + usize::try_from(configured_threads).expect("nonnegative artifact worker limit"), + admitted_auxiliary_threads, + "SQLite must receive the maximum auxiliary width available below the canonical worker bound and its own compile-time ceiling" + ); + } + + #[test] + fn builder_sorter_statements_hold_their_weighted_cpu_width() { + let worker_width = tracedecay_code_index::parallelism::indexing_workers(); + let authority = tracedecay_runtime_core::background_cpu::install_process_background_cpu( + NonZeroUsize::new(worker_width).expect("nonzero code-index worker width"), + ) + .expect("install matching process background CPU authority"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let connection = open_builder_connection(&directory.path().join("weighted.sqlite")) + .expect("builder connection"); + let configured_threads: i64 = connection + .pragma_query_value(None, "threads", |row| row.get(0)) + .expect("read configured SQLite helper width"); + let expected_units = usize::try_from(configured_threads) + .expect("nonnegative SQLite helper width") + .saturating_add(1) + .min(worker_width); + + let observed_units = + with_builder_sorter_cpu_admission(&connection, || authority.active_units()) + .expect("run weighted SQLite statement"); + + assert_eq!( + observed_units, expected_units, + "one builder plus every configured SQLite helper must share the process CPU authority" + ); + assert_eq!( + authority.active_units(), + 0, + "weighted admission must release every unit after the statement" + ); } } diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index 81f605a655..106555d93b 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -1,9 +1,15 @@ -use std::borrow::Cow; -use std::collections::BTreeMap; -use std::collections::btree_map::Entry; +use std::cmp::{Ordering as CmpOrdering, Reverse}; +use std::collections::BinaryHeap; use std::fs::File; +use std::num::NonZeroUsize; +use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::time::Duration; +use rayon::prelude::*; +use rusqlite::functions::FunctionFlags; use rusqlite::types::ValueRef; use rusqlite::{Connection, OptionalExtension, Transaction, params}; use serde::{Deserialize, Serialize}; @@ -16,39 +22,285 @@ use tracedecay_code_index::production::{ VerifiedSealedLexicalSourceReceiptV1, }; use tracedecay_domain::{ - CodeSearchChunkAnchorV1, CodeSearchChunkV1, ExactFieldV1, ExactTechnicalTermV1, - FileOccurrenceId, ManifestDigest, + CodeSearchChunkAnchorV1, CodeSearchChunkV1, ExactTechnicalTermV1, FileOccurrenceId, + ManifestDigest, }; use tracedecay_private_fs::{create_private_file_retained, open_private_file}; use super::format::{ - ArtifactRowV1, CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1, CodeLexicalArtifactSectionDigestV1, - RECEIPT_RESERVATION_BYTES, SECTION_NAMES, VerifiedCodeLexicalArtifactV1, artifact_digest, - decode_padded_receipt, decode_padded_receipt_with_control, encode_exact_field, encode_field, - metadata_digest, new_verified_receipt, padded_receipt, verify_required_artifact_indexes, + BASE_SECTION_NAMES, CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1, + CodeLexicalArtifactSectionDigestV1, RECEIPT_RESERVATION_BYTES, SECTION_NAMES, + VerifiedCodeLexicalArtifactV1, absorb_page_base_sections_receipt, artifact_digest, + decode_padded_receipt, decode_padded_receipt_with_control, encode_field, + finish_base_section_receipt_fold, initial_base_section_receipt_fold, metadata_digest, + new_verified_receipt, padded_receipt, verify_artifact_table_layout, + verify_required_artifact_indexes, }; -use super::postings::{ - NGRAM_NORMALIZED, NGRAM_RAW_OVERRIDE, document_ngram_scratch, insert_document_ngrams, +use super::postings::document_ngram_scratch; +use super::prepared::{ + PreparedCodeLexicalArtifactPageV1, PreparedTermPostingV1, prepare_page as prepare_page_values, }; use super::{ ARTIFACT_SQLITE_CACHE_BYTES, CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, - CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1, CodeLexicalArtifactErrorV1, checkpoint, + CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1, + CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1, + CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1, CodeLexicalArtifactBatchLimitV1, + CodeLexicalArtifactErrorV1, NGRAM_AGGREGATION_BYTES_PER_LOGICAL_POSTING_V1, checkpoint, open_builder_connection, sqlite_corrupt, sqlite_error, }; use crate::retrieval::lexical::LexicalFieldV1; -use super::super::{ - CodeLexicalProjectionMetadataV1, ProjectedChunkV1, canonical_projected_exact_term, - exact_field_for_kind, -}; +use super::super::CodeLexicalProjectionMetadataV1; -const DOCUMENT_TERM_POSTINGS_QUERY: &str = "SELECT field, term, frequency FROM term_postings INDEXED BY term_postings_by_document WHERE document_id = ?1 ORDER BY field, term"; -const DOCUMENT_EXACT_POSTINGS_QUERY: &str = - "SELECT field, term FROM exact_postings WHERE document_id = ?1 ORDER BY field, term"; -const DOCUMENT_NGRAM_POSTINGS_QUERY: &str = - "SELECT kind, ngram FROM ngram_postings WHERE document_id = ?1 ORDER BY kind, ngram"; const PROGRESS_TAIL_QUERY: &str = "SELECT page_ordinal, import_dictionary_digest, cumulative_digest, next_cursor \ FROM source_pages ORDER BY page_ordinal DESC LIMIT 1"; +const FINALIZATION_PROGRESS_INTERVAL_OPS: i32 = 4_096; +const FINALIZATION_CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(1); +// A plan entry owns one document id and one posting reference. Three words +// cover its portable 32-bit layout and conservatively exceed its 64-bit +// layout; general allocator metadata remains outside the ledger contract. +const TERM_INSERT_PLAN_BYTES_PER_REF: usize = 3 * std::mem::size_of::(); +const TERM_INSERT_CONTROL_INTERVAL: usize = 4_096; +const TERM_INSERT_SORT_RUN_ROWS: usize = 4_096; +// This gate serializes mutation within the private-profile/stable-handle +// authority. It denies ordinary second-connection DML, but is not a +// cryptographic defense against malicious same-UID code that deliberately +// registers a lookalike SQLite function. +const BUILDER_MUTATION_GATE_FUNCTION: &str = "tracedecay_lexical_builder_append_authorized"; +const BUILDER_MUTATION_IDLE: u8 = 0; +const BUILDER_MUTATION_APPEND: u8 = 1; + +#[derive(Clone, Copy)] +struct PreparedTermInsertRefV1<'a> { + document_id: i64, + posting: &'a PreparedTermPostingV1, +} + +impl PreparedTermInsertRefV1<'_> { + fn key(&self) -> (&str, &str, i64) { + ( + self.posting.field.as_str(), + self.posting.term.as_str(), + self.document_id, + ) + } +} + +#[derive(Clone, Copy)] +struct PreparedTermMergeCursorV1<'a> { + entry: PreparedTermInsertRefV1<'a>, + run_index: usize, + run_offset: usize, +} + +impl PartialEq for PreparedTermMergeCursorV1<'_> { + fn eq(&self, other: &Self) -> bool { + self.entry.key() == other.entry.key() + && self.run_index == other.run_index + && self.run_offset == other.run_offset + } +} + +impl Eq for PreparedTermMergeCursorV1<'_> {} + +impl PartialOrd for PreparedTermMergeCursorV1<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PreparedTermMergeCursorV1<'_> { + fn cmp(&self, other: &Self) -> CmpOrdering { + self.entry + .key() + .cmp(&other.entry.key()) + .then_with(|| self.run_index.cmp(&other.run_index)) + .then_with(|| self.run_offset.cmp(&other.run_offset)) + } +} + +struct PreparedTermInsertPlanV1<'a> { + entries: Vec>, + merge_heap: BinaryHeap>>, +} + +const BUILDER_GATE_TRIGGER_LAYOUT: [(&str, &str, &str); 14] = [ + ("builder_gate_source_pages_insert", "source_pages", "INSERT"), + ( + "builder_gate_document_integrity_insert", + "document_integrity", + "INSERT", + ), + ( + "builder_gate_import_integrity_insert", + "import_integrity", + "INSERT", + ), + ( + "builder_gate_import_evidence_insert", + "import_evidence", + "INSERT", + ), + ("builder_gate_rows_insert", "rows", "INSERT"), + ("builder_gate_rows_update", "rows", "UPDATE"), + ("builder_gate_rows_delete", "rows", "DELETE"), + ( + "builder_gate_term_postings_insert", + "term_postings", + "INSERT", + ), + ( + "builder_gate_term_postings_update", + "term_postings", + "UPDATE", + ), + ( + "builder_gate_term_postings_delete", + "term_postings", + "DELETE", + ), + ( + "builder_gate_exact_postings_insert", + "exact_postings", + "INSERT", + ), + ( + "builder_gate_exact_postings_update", + "exact_postings", + "UPDATE", + ), + ( + "builder_gate_exact_postings_delete", + "exact_postings", + "DELETE", + ), + ( + "builder_gate_ngram_postings_insert", + "ngram_postings", + "INSERT", + ), +]; +const IMMUTABLE_TRIGGER_LAYOUT: [(&str, &str, &str, &str); 10] = [ + ( + "immutable_source_pages_update", + "source_pages", + "UPDATE", + "immutable lexical source pages", + ), + ( + "immutable_source_pages_delete", + "source_pages", + "DELETE", + "immutable lexical source pages", + ), + ( + "immutable_document_integrity_update", + "document_integrity", + "UPDATE", + "immutable lexical document integrity", + ), + ( + "immutable_document_integrity_delete", + "document_integrity", + "DELETE", + "immutable lexical document integrity", + ), + ( + "immutable_import_integrity_update", + "import_integrity", + "UPDATE", + "immutable lexical import integrity", + ), + ( + "immutable_import_integrity_delete", + "import_integrity", + "DELETE", + "immutable lexical import integrity", + ), + ( + "immutable_import_evidence_update", + "import_evidence", + "UPDATE", + "immutable lexical import evidence", + ), + ( + "immutable_import_evidence_delete", + "import_evidence", + "DELETE", + "immutable lexical import evidence", + ), + ( + "immutable_ngram_postings_update", + "ngram_postings", + "UPDATE", + "immutable lexical ngram postings", + ), + ( + "immutable_ngram_postings_delete", + "ngram_postings", + "DELETE", + "immutable lexical ngram postings", + ), +]; + +struct BuilderMutationGuardV1 { + gate: Arc, +} + +impl BuilderMutationGuardV1 { + fn enter(gate: &Arc) -> Result { + gate.compare_exchange( + BUILDER_MUTATION_IDLE, + BUILDER_MUTATION_APPEND, + Ordering::AcqRel, + Ordering::Acquire, + ) + .map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact builder mutation authority is already active".to_owned(), + ) + })?; + Ok(Self { + gate: Arc::clone(gate), + }) + } +} + +impl Drop for BuilderMutationGuardV1 { + fn drop(&mut self) { + self.gate.store(BUILDER_MUTATION_IDLE, Ordering::Release); + } +} + +fn register_builder_mutation_gate( + connection: &Connection, +) -> Result, CodeLexicalArtifactErrorV1> { + let gate = Arc::new(AtomicU8::new(BUILDER_MUTATION_IDLE)); + let function_gate = Arc::clone(&gate); + connection + .create_scalar_function( + BUILDER_MUTATION_GATE_FUNCTION, + 0, + FunctionFlags::SQLITE_UTF8, + move |_| { + Ok(i64::from( + function_gate.load(Ordering::Acquire) == BUILDER_MUTATION_APPEND, + )) + }, + ) + .map_err(sqlite_error)?; + Ok(gate) +} + +#[cfg(test)] +std::thread_local! { + static FAIL_NEXT_FINALIZATION_MONITOR_SPAWN: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +fn fail_next_finalization_monitor_spawn() { + FAIL_NEXT_FINALIZATION_MONITOR_SPAWN.with(|failure| failure.set(true)); +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FinalizationSectionV1 { @@ -107,10 +359,10 @@ impl FinalizationSectionV1 { const fn full_query(self) -> &'static str { match self { Self::SourcePages => { - "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor FROM source_pages ORDER BY page_ordinal" + "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, base_sections_receipt, next_cursor FROM source_pages ORDER BY page_ordinal" } Self::DocumentIntegrity => { - "SELECT document_id, digest FROM document_integrity ORDER BY document_id" + "SELECT document_id, chunk_id, digest FROM document_integrity ORDER BY document_id" } Self::ImportIntegrity => { "SELECT canonical, digest FROM import_integrity ORDER BY canonical" @@ -126,7 +378,7 @@ impl FinalizationSectionV1 { "SELECT field, term, document_id FROM exact_postings ORDER BY field, term, document_id" } Self::NgramPostings => { - "SELECT kind, ngram, document_id FROM ngram_postings ORDER BY kind, ngram, document_id" + "SELECT page_ordinal, kind, ngram, documents, cardinality FROM ngram_postings ORDER BY page_ordinal, kind, ngram" } Self::FieldStatistics => "SELECT field, total_length FROM field_stats ORDER BY field", Self::TermStatistics => { @@ -140,16 +392,16 @@ impl FinalizationSectionV1 { const fn seek_query(self, after: bool) -> &'static str { match (self, after) { (Self::SourcePages, false) => { - "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor FROM source_pages ORDER BY page_ordinal LIMIT ?1" + "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, base_sections_receipt, next_cursor FROM source_pages ORDER BY page_ordinal LIMIT ?1" } (Self::SourcePages, true) => { - "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor FROM source_pages WHERE page_ordinal > ?1 ORDER BY page_ordinal LIMIT ?2" + "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, base_sections_receipt, next_cursor FROM source_pages WHERE page_ordinal > ?1 ORDER BY page_ordinal LIMIT ?2" } (Self::DocumentIntegrity, false) => { - "SELECT document_id, digest FROM document_integrity ORDER BY document_id LIMIT ?1" + "SELECT document_id, chunk_id, digest FROM document_integrity ORDER BY document_id LIMIT ?1" } (Self::DocumentIntegrity, true) => { - "SELECT document_id, digest FROM document_integrity WHERE document_id > ?1 ORDER BY document_id LIMIT ?2" + "SELECT document_id, chunk_id, digest FROM document_integrity WHERE document_id > ?1 ORDER BY document_id LIMIT ?2" } (Self::ImportIntegrity, false) => { "SELECT canonical, digest FROM import_integrity ORDER BY canonical LIMIT ?1" @@ -182,10 +434,10 @@ impl FinalizationSectionV1 { "SELECT field, term, document_id FROM exact_postings WHERE (field, term, document_id) > (?1, ?2, ?3) ORDER BY field, term, document_id LIMIT ?4" } (Self::NgramPostings, false) => { - "SELECT kind, ngram, document_id FROM ngram_postings ORDER BY kind, ngram, document_id LIMIT ?1" + "SELECT page_ordinal, kind, ngram, documents, cardinality FROM ngram_postings ORDER BY page_ordinal, kind, ngram LIMIT ?1" } (Self::NgramPostings, true) => { - "SELECT kind, ngram, document_id FROM ngram_postings WHERE (kind, ngram, document_id) > (?1, ?2, ?3) ORDER BY kind, ngram, document_id LIMIT ?4" + "SELECT page_ordinal, kind, ngram, documents, cardinality FROM ngram_postings WHERE (page_ordinal, kind, ngram) > (?1, ?2, ?3) ORDER BY page_ordinal, kind, ngram LIMIT ?4" } (Self::FieldStatistics, false) => { "SELECT field, total_length FROM field_stats ORDER BY field LIMIT ?1" @@ -224,9 +476,9 @@ enum PersistedFinalizationKeyV1 { document_id: i64, }, IntegerIntegerInteger { + page_ordinal: i64, kind: i64, ngram: i64, - document_id: i64, }, TextText { field: String, @@ -280,9 +532,16 @@ pub struct CodeLexicalArtifactBuildProgressV1 { /// `Pending` persists its section and row cursor in the staging database, so /// callers can yield, restart the process, and continue without reopening the /// sealed source or replaying its pages. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CodeLexicalArtifactFinalizationPhaseV1 { + IndexBuild, + Verification, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum CodeLexicalArtifactFinalizationStepV1 { Pending { + phase: CodeLexicalArtifactFinalizationPhaseV1, completed_sections: u64, completed_rows: u64, }, @@ -297,6 +556,8 @@ struct PersistedFinalizationStateV1 { section_row_count: u64, section_last_key: Option, section_accumulator: Vec, + base_section_row_counts: Vec, + base_section_accumulators: Vec>, completed_sections: Vec, completed_rows: u64, content_epoch: i64, @@ -306,27 +567,80 @@ struct PersistedFinalizationStateV1 { #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum PersistedFinalizationPhaseV1 { - Build, - Verify, + Statistics, + Indexes, + Digest, +} + +impl PersistedFinalizationPhaseV1 { + const fn public(self) -> CodeLexicalArtifactFinalizationPhaseV1 { + match self { + Self::Statistics | Self::Indexes => CodeLexicalArtifactFinalizationPhaseV1::IndexBuild, + Self::Digest => CodeLexicalArtifactFinalizationPhaseV1::Verification, + } + } +} + +struct FinalizationWakeMetricsV1 { + #[cfg(feature = "hotpath")] + rows: u64, +} + +struct FinalizationTransactionMetricsV1 { + #[cfg(feature = "hotpath")] + committed: bool, } -struct FinalizationWakeMetricsV1; +impl FinalizationTransactionMetricsV1 { + #[inline(always)] + const fn new() -> Self { + Self { + #[cfg(feature = "hotpath")] + committed: false, + } + } + + #[inline(always)] + fn mark_committed(&mut self) { + #[cfg(feature = "hotpath")] + { + self.committed = true; + } + } +} + +impl Drop for FinalizationTransactionMetricsV1 { + fn drop(&mut self) { + #[cfg(feature = "hotpath")] + if !self.committed { + // Dropping an uncommitted rusqlite transaction rolls it back. + hotpath::gauge!("query.artifact.finalization.rollback_total").inc(1u64); + } + } +} impl FinalizationWakeMetricsV1 { #[inline(always)] fn new() -> Self { - Self + Self { + #[cfg(feature = "hotpath")] + rows: 0, + } } #[inline(always)] fn digest_pass(&self, pass: PersistedFinalizationPhaseV1) { #[cfg(feature = "hotpath")] match pass { - PersistedFinalizationPhaseV1::Build => { - hotpath::gauge!("query.artifact.finalization.digest_pass.build_total").inc(1u64); + PersistedFinalizationPhaseV1::Statistics => { + hotpath::gauge!("query.artifact.finalization.statistics_wakes_total").inc(1u64); } - PersistedFinalizationPhaseV1::Verify => { - hotpath::gauge!("query.artifact.finalization.digest_pass.verify_total").inc(1u64); + PersistedFinalizationPhaseV1::Indexes => { + hotpath::gauge!("query.artifact.finalization.index_wakes_total").inc(1u64); + } + PersistedFinalizationPhaseV1::Digest => { + hotpath::gauge!("query.artifact.finalization.digest_pass.authenticated_total") + .inc(1u64); } }; #[cfg(not(feature = "hotpath"))] @@ -383,12 +697,34 @@ impl FinalizationWakeMetricsV1 { #[cfg(feature = "hotpath")] hotpath::gauge!("query.artifact.finalization.section_probes_total").inc(1u64); } + + #[inline(always)] + fn add_rows(&mut self, rows: usize) -> Result<(), CodeLexicalArtifactErrorV1> { + #[cfg(feature = "hotpath")] + { + self.rows = self + .rows + .checked_add(u64::try_from(rows).map_err(contract_number)?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact finalization wake row metric overflowed".to_owned(), + ) + })?; + } + #[cfg(not(feature = "hotpath"))] + let _ = rows; + Ok(()) + } } -#[inline(always)] -fn record_finalization_row() { - #[cfg(feature = "hotpath")] - hotpath::gauge!("query.artifact.finalization.rows_total").inc(1u64); +impl Drop for FinalizationWakeMetricsV1 { + fn drop(&mut self) { + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.finalization.wakes_total").inc(1u64); + hotpath::gauge!("query.artifact.finalization.rows_total").inc(self.rows); + } + } } /// Stable identity of the private staging authority, captured from an exact @@ -413,12 +749,31 @@ pub struct CodeLexicalArtifactBuilderV1 { _private_file: File, file_identity: StableArtifactFileIdentityV1, connection: Connection, + mutation_gate: Arc, metadata: CodeLexicalProjectionMetadataV1, metadata_digest: ManifestDigest, memory_budget_bytes: usize, fixed_ledger_charge_bytes: usize, } +/// One source-prefix decision whose fresh relational values were prepared +/// exactly once. Replayed pages contribute to `accepted_prefix` but do not +/// appear in `prepared_pages` because they require no SQLite mutation. +pub struct PreparedCodeLexicalArtifactBatchV1 { + accepted_prefix: NonZeroUsize, + prepared_pages: Vec, +} + +impl PreparedCodeLexicalArtifactBatchV1 { + pub fn accepted_prefix(&self) -> NonZeroUsize { + self.accepted_prefix + } + + pub fn prepared_pages(&self) -> &[PreparedCodeLexicalArtifactPageV1] { + &self.prepared_pages + } +} + impl CodeLexicalArtifactBuilderV1 { pub fn create( path: impl AsRef, @@ -449,7 +804,9 @@ impl CodeLexicalArtifactBuilderV1 { )); } let (connection, private_file, file_identity) = create_private_builder_connection(path)?; + let mutation_gate = register_builder_mutation_gate(&connection)?; create_schema(&connection)?; + verify_builder_mutation_gate_schema(&connection)?; let metadata_digest = metadata_digest(&metadata)?; let metadata_bytes = serde_json::to_vec(&metadata) .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; @@ -470,6 +827,7 @@ impl CodeLexicalArtifactBuilderV1 { _private_file: private_file, file_identity, connection, + mutation_gate, metadata, metadata_digest, memory_budget_bytes, @@ -495,11 +853,21 @@ impl CodeLexicalArtifactBuilderV1 { validated_fixed_ledger_charge(&expected_metadata, memory_budget_bytes)?; let path = path.as_ref(); let (connection, private_file, file_identity) = open_private_builder_connection(path)?; + let mutation_gate = register_builder_mutation_gate(&connection)?; require_integrity(&connection, control)?; let expected_digest = metadata_digest(&expected_metadata)?; verify_artifact_state_metadata(&connection, &expected_metadata, &expected_digest, control)?; - verify_required_artifact_indexes(&connection)?; - read_receipt_with_control(&connection, control)?; + verify_artifact_table_layout(&connection)?; + verify_builder_mutation_gate_schema(&connection)?; + let receipt = read_receipt_with_control(&connection, control)?; + let finalization = load_finalization_state(&connection)?; + if receipt.is_some() + || finalization + .as_ref() + .is_some_and(|state| state.phase == PersistedFinalizationPhaseV1::Digest) + { + verify_required_artifact_indexes(&connection)?; + } validate_contiguous_pages(&connection, control)?; checkpoint(control)?; crate::hotpath_metrics::Residency::Rebuilding.record("query.artifact.residency"); @@ -508,6 +876,7 @@ impl CodeLexicalArtifactBuilderV1 { _private_file: private_file, file_identity, connection, + mutation_gate, metadata: expected_metadata, metadata_digest: expected_digest, memory_budget_bytes, @@ -530,14 +899,14 @@ impl CodeLexicalArtifactBuilderV1 { /// The deterministic ledger charge admitting `page` would add on top of /// the fixed charge: the page's retained owned bytes plus the - /// arithmetic per-chunk/per-import transient upper bound (chunk clone, - /// projected row, field/token/frequency maps, JSON buffers, and n-gram - /// scratch), without allocating during admission. + /// summed per-record preparation upper bound (projected rows, postings, + /// serialization, and n-gram scratch), without allocating during + /// admission. pub fn page_ledger_charge_bytes( &self, page: &VerifiedSealedLexicalPageV1, ) -> Result { - let transient = page_transient_peak_bytes(&self.metadata, page, usize::MAX)?; + let transient = page_preparation_upper_bound_bytes(&self.metadata, page)?; page.retained_owned_bytes() .checked_add(transient) .ok_or_else(|| { @@ -547,14 +916,204 @@ impl CodeLexicalArtifactBuilderV1 { }) } - #[hotpath::measure(label = "query.artifact.append_page")] + /// Conservative pre-preparation charge for retaining `pages` and every + /// page's derived output/scratch upper bound. The exact post-preparation + /// charge is carried by [`PreparedCodeLexicalArtifactPageV1`]. + pub fn page_batch_ledger_charge_bytes( + &self, + pages: &[VerifiedSealedLexicalPageV1], + ) -> Result { + page_batch_ledger_charge_bytes(&self.metadata, pages) + } + + /// Return the largest contiguous input prefix whose complete retained, + /// prepared-output, and active-worker scratch claims fit the memory + /// authority. Exact prepared-row and SQLite-write prefix selection occurs + /// after this bound in [`Self::prepare_admissible_page_prefix`]. Zero is + /// truthful when even the first page cannot be prepared within memory. + pub fn largest_admissible_page_prefix( + &self, + pages: &[VerifiedSealedLexicalPageV1], + ) -> Result { + self.verify_path_binding()?; + let worker_limit = tracedecay_code_index::parallelism::indexing_workers(); + let mut retained = 0usize; + let mut prepared = 0usize; + let mut active_scratch = 0usize; + let mut largest_scratch = BinaryHeap::>::new(); + for (index, page) in pages.iter().enumerate() { + if page.retained_owned_bytes() > CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 { + return Err(CodeLexicalArtifactErrorV1::Contract(format!( + "sealed lexical page retained bytes exceed the {}-byte artifact input bound", + CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 + ))); + } + retained = retained + .checked_add(page.retained_owned_bytes()) + .ok_or_else(batch_ledger_overflow)?; + prepared = prepared + .checked_add(page_prepared_retained_upper_bound_bytes( + &self.metadata, + page, + )?) + .ok_or_else(batch_ledger_overflow)?; + let scratch = page_transient_peak_bytes(&self.metadata, page, usize::MAX)?; + if largest_scratch.len() < worker_limit { + largest_scratch.push(Reverse(scratch)); + active_scratch = active_scratch + .checked_add(scratch) + .ok_or_else(batch_ledger_overflow)?; + } else if let Some(Reverse(smallest)) = largest_scratch.peek().copied() + && scratch > smallest + { + largest_scratch.pop(); + largest_scratch.push(Reverse(scratch)); + active_scratch = active_scratch + .checked_sub(smallest) + .and_then(|bytes| bytes.checked_add(scratch)) + .ok_or_else(batch_ledger_overflow)?; + } + let required = self + .fixed_ledger_charge_bytes + .checked_add(retained) + .and_then(|bytes| bytes.checked_add(prepared)) + .and_then(|bytes| bytes.checked_add(active_scratch)) + .ok_or_else(batch_ledger_overflow)?; + if required > self.memory_budget_bytes { + return Ok(index); + } + } + Ok(pages.len()) + } + + /// Append one page through the canonical atomic batch path. pub fn append_page( &mut self, page: &VerifiedSealedLexicalPageV1, control: &dyn CodeIndexExecutionControlV1, ) -> Result { + self.append_pages(std::slice::from_ref(page), control) + } + + /// Atomically append an ordered, contiguous batch of verified source + /// pages. Replayed prefix pages are verified idempotently; every fresh + /// page and its derived rows commit in one SQLite transaction. + #[hotpath::measure(label = "query.artifact.append_pages")] + pub fn append_pages( + &mut self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result { + let result = (|| { + let prepared = self.prepare_pages(pages, control)?; + self.append_prepared_pages_inner(&prepared, control) + })(); + record_batch_outcome(&result); + result + } + + /// Prepare the fresh suffix of one ordered source batch outside SQLite. + /// Work runs on the canonical bounded indexing pool, preserves input + /// order, holds one background CPU permit per active unit, and drains all + /// workers before returning any failure. + #[hotpath::measure(label = "query.artifact.prepare_pages")] + pub fn prepare_pages( + &self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result, CodeLexicalArtifactErrorV1> { + let (_, prepared) = self.prepare_pages_inner(pages, control)?; + admit_prepared_page_batch( + self.fixed_ledger_charge_bytes, + self.memory_budget_bytes, + &prepared, + )?; + record_prepared_batch_metrics(&prepared); + Ok(prepared) + } + + /// Memory-bound an offered source batch, prepare that prefix once, then + /// select the largest exact prepared prefix admitted by the row and + /// estimated-write authorities. This avoids conservative pre-dedup row + /// estimates while preserving every exact post-preparation cap. + pub fn prepare_admissible_page_prefix( + &self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result { + if pages.is_empty() { + return Err(CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page batches must be non-empty".to_owned(), + )); + } + let memory_prefix = self.largest_admissible_page_prefix(pages)?; + if memory_prefix == 0 { + record_batch_prefix_limit(CodeLexicalArtifactBatchLimitV1::Memory); + admit_page_batch_within_memory_budget( + &self.metadata, + self.fixed_ledger_charge_bytes, + self.memory_budget_bytes, + &pages[..1], + )?; + return Err(CodeLexicalArtifactErrorV1::Contract( + "lexical artifact memory prefix rejected a separately admissible first page" + .to_owned(), + )); + } + let (replayed_prefix, mut prepared) = + self.prepare_pages_inner(&pages[..memory_prefix], control)?; + let (fresh_prefix, exact_limit) = largest_exact_prepared_prefix( + &prepared, + self.fixed_ledger_charge_bytes, + self.memory_budget_bytes, + )?; + if fresh_prefix == 0 && !prepared.is_empty() { + let exceeded = exact_limit.ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact exact prefix rejected a page without a limiting authority" + .to_owned(), + ) + })?; + record_batch_prefix_limit(exceeded.limit); + return Err(batch_limit( + exceeded.limit, + exceeded.required, + exceeded.maximum, + )); + } + prepared.truncate(fresh_prefix); + let accepted = replayed_prefix + .checked_add(fresh_prefix) + .ok_or_else(batch_ledger_overflow)?; + let accepted_prefix = NonZeroUsize::new(accepted).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact admissible source prefix was empty".to_owned(), + ) + })?; + if let Some(exceeded) = exact_limit { + record_batch_prefix_limit(exceeded.limit); + } else if memory_prefix < pages.len() { + record_batch_prefix_limit(CodeLexicalArtifactBatchLimitV1::Memory); + } + record_prepared_batch_metrics(&prepared); + Ok(PreparedCodeLexicalArtifactBatchV1 { + accepted_prefix, + prepared_pages: prepared, + }) + } + + fn prepare_pages_inner( + &self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result<(usize, Vec), CodeLexicalArtifactErrorV1> { checkpoint(control)?; self.verify_path_binding()?; + if pages.is_empty() { + return Err(CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page batches must be non-empty".to_owned(), + )); + } if read_receipt(&self.connection)?.is_some() { return Err(CodeLexicalArtifactErrorV1::Contract( "finalized lexical artifacts do not accept more source pages".to_owned(), @@ -565,68 +1124,202 @@ impl CodeLexicalArtifactBuilderV1 { "lexical artifact finalization has started; source pages are immutable".to_owned(), )); } - if page.retained_owned_bytes() > CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 { - return Err(CodeLexicalArtifactErrorV1::Contract(format!( - "sealed lexical page retained bytes exceed the {}-byte artifact input bound", - CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 - ))); - } - let current = progress(&self.connection)?; - let previous = cursor_before_page(&self.connection, page.page_ordinal())?; - page.verify_transition(previous.as_ref()) - .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; - if page.page_ordinal() < current.next_page_ordinal { - verify_replayed_page(&self.connection, page)?; - hotpath::gauge!("query.artifact.pages").set(current.next_page_ordinal); - hotpath::gauge!("query.artifact.rows").set(current.completed_chunks); - hotpath::gauge!("query.artifact.bytes").set(current.completed_payload_bytes); - return Ok(current); + + hotpath::gauge!("query.artifact.batch.admission_total").inc(1u64); + let (current, fresh_start) = hotpath::measure_block!("query.artifact.batch.admission", { + prepare_page_batch_admission( + &self.connection, + &self.metadata, + self.fixed_ledger_charge_bytes, + self.memory_budget_bytes, + pages, + ) + })?; + let fresh_pages = &pages[fresh_start..]; + if fresh_pages.is_empty() { + return Ok((fresh_start, Vec::new())); } - if page.page_ordinal() != current.next_page_ordinal { + let previous_cursors = fresh_pages + .iter() + .enumerate() + .map(|(index, _)| { + if index == 0 { + current.next_cursor.as_ref().map(encode_cursor).transpose() + } else { + encode_cursor(fresh_pages[index - 1].next_cursor()).map(Some) + } + }) + .collect::, _>>()?; + let scratch = fresh_pages + .iter() + .map(|page| page_transient_peak_bytes(&self.metadata, page, usize::MAX)) + .collect::, _>>()?; + let metadata = &self.metadata; + let prepared = hotpath::measure_block!("query.artifact.batch.parallel_prepare", { + tracedecay_code_index::parallelism::install(|| { + fresh_pages + .par_iter() + .zip(previous_cursors.into_par_iter()) + .zip(scratch.into_par_iter()) + .enumerate() + .map(|(index, ((page, previous_cursor), scratch_bytes))| { + tracedecay_code_index::parallelism::with_background_cpu_permit(|| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + prepare_page_values( + metadata, + page, + previous_cursor, + scratch_bytes, + control, + ) + })) + .unwrap_or_else(|payload| { + Err(CodeLexicalArtifactErrorV1::Io( + tracedecay_code_index::parallelism::CodeIndexParallelismErrorV1::from_panic_payload( + index, + &*payload, + ) + .to_string(), + )) + }) + }) + }) + .collect::>() + }) + }) + .map_err(|error| CodeLexicalArtifactErrorV1::Io(error.to_string()))? + .into_iter() + .collect::, _>>()?; + Ok((fresh_start, prepared)) + } + + /// Atomically admit an ordered prepared batch. The values carry no + /// durable authority until this method commits their rows and receipts. + pub fn append_prepared_pages( + &mut self, + pages: &[PreparedCodeLexicalArtifactPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result { + let result = self.append_prepared_pages_inner(pages, control); + record_batch_outcome(&result); + result + } + + fn append_prepared_pages_inner( + &mut self, + pages: &[PreparedCodeLexicalArtifactPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result { + checkpoint(control)?; + self.verify_path_binding()?; + if read_receipt(&self.connection)?.is_some() { return Err(CodeLexicalArtifactErrorV1::Contract( - "sealed lexical pages must be appended in exact ordinal order".to_owned(), + "finalized lexical artifacts do not accept more source pages".to_owned(), )); } - if let Some(cumulative) = ¤t.cumulative_source_digest - && page.page_ordinal() > 0 - && cumulative == page.cumulative_digest() - { + if finalization_started(&self.connection)? { return Err(CodeLexicalArtifactErrorV1::Contract( - "sealed lexical page did not advance its cumulative digest".to_owned(), + "lexical artifact finalization has started; source pages are immutable".to_owned(), )); } - // Ledger refusal precedes the staging transaction: a page that does - // not fit the build memory budget leaves progress untouched. - admit_page_within_memory_budget( - &self.metadata, + let current = progress(&self.connection)?; + if pages.is_empty() { + record_artifact_progress(¤t); + return Ok(current); + } + validate_prepared_page_batch(¤t, pages)?; + admit_prepared_page_batch( self.fixed_ledger_charge_bytes, self.memory_budget_bytes, - page, + pages, )?; - - let transaction = self.connection.transaction().map_err(sqlite_error)?; - append_imports(&transaction, page, control)?; - append_page_rows( - &transaction, - &self.metadata, - current.completed_chunks, - page, - control, + let mut term_insert_plan = hotpath::measure_block!( + "query.artifact.batch.term_order", + prepare_term_insert_plan( + self.fixed_ledger_charge_bytes, + self.memory_budget_bytes, + pages, + control, + ) )?; - insert_source_page(&transaction, page)?; - checkpoint(control)?; - transaction.commit().map_err(sqlite_error)?; + hotpath::measure_block!("query.artifact.batch.sqlite", { + let _mutation_authority = BuilderMutationGuardV1::enter(&self.mutation_gate)?; + let transaction = self.connection.transaction().map_err(sqlite_error)?; + let mutation = (|| { + hotpath::measure_block!("query.artifact.batch.imports", { + for page in pages { + append_prepared_imports(&transaction, page, control)?; + } + Ok::<(), CodeLexicalArtifactErrorV1>(()) + })?; + record_batch_import_metrics(pages); + hotpath::measure_block!( + "query.artifact.batch.rows", + append_prepared_rows(&transaction, pages, control) + )?; + record_batch_row_metrics(pages); + hotpath::measure_block!( + "query.artifact.batch.postings", + append_prepared_postings(&transaction, pages, &mut term_insert_plan, control) + )?; + record_batch_posting_metrics(pages); + hotpath::measure_block!("query.artifact.batch.receipts", { + for page in pages { + insert_prepared_source_page(&transaction, page)?; + } + Ok::<(), CodeLexicalArtifactErrorV1>(()) + })?; + record_batch_receipt_metrics(pages); + checkpoint(control) + })(); + if let Err(error) = mutation { + hotpath::gauge!("query.artifact.batch.rollbacks_total").inc(1u64); + hotpath::measure_block!( + "query.artifact.batch.rollback", + transaction.rollback().map_err(sqlite_error) + )?; + return Err(error); + } + hotpath::gauge!("query.artifact.batch.commit_attempts_total").inc(1u64); + let commit = hotpath::measure_block!( + "query.artifact.batch.commit", + transaction.commit().map_err(sqlite_error) + ); + if commit.is_ok() { + hotpath::gauge!("query.artifact.batch.commit_succeeded_total").inc(1u64); + } + commit + })?; + // Do not observe cancellation between durable COMMIT and publishing + // its exact progress. The source callback must be able to advance its + // cursor once the whole batch has committed. let progress = progress(&self.connection)?; - hotpath::gauge!("query.artifact.pages").set(progress.next_page_ordinal); - hotpath::gauge!("query.artifact.rows").set(progress.completed_chunks); - hotpath::gauge!("query.artifact.bytes").set(progress.completed_payload_bytes); + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.batch.committed_pages_total") + .inc(u64::try_from(pages.len()).map_err(contract_number)?); + hotpath::gauge!("query.artifact.batch.committed_chunks_total").inc( + pages + .iter() + .try_fold(0u64, |total, page| total.checked_add(page.chunk_count)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact committed chunk count overflowed".to_owned(), + ) + })?, + ); + } + record_artifact_progress(&progress); Ok(progress) } /// Advance durable receipt construction without rereading the sealed - /// generation. `maximum_work` bounds the number of staged rows (or empty + /// generation. Before digest verification, one wake commits exactly one + /// set-wise statistics or serving-index statement and SQLite VM progress + /// observes cancellation during that statement. During digest + /// verification, `maximum_work` bounds the number of staged rows (or empty /// section completions) this call may consume. - #[hotpath::measure(label = "query.artifact.advance_finalization")] + #[hotpath::measure(label = "query.artifact.finalization.advance_wake")] pub fn advance_finalization( &mut self, source: &VerifiedSealedLexicalSourceReceiptV1, @@ -638,7 +1331,7 @@ impl CodeLexicalArtifactBuilderV1 { "lexical artifact finalization work budget must be non-zero".to_owned(), )); } - let wake_metrics = FinalizationWakeMetricsV1::new(); + let mut wake_metrics = FinalizationWakeMetricsV1::new(); checkpoint(control)?; self.verify_path_binding()?; verify_artifact_state_metadata( @@ -655,17 +1348,28 @@ impl CodeLexicalArtifactBuilderV1 { } if load_finalization_state(&self.connection)?.is_none() { - verify_staged_source_tail(&self.connection, source)?; - let content_epoch = content_epoch(&self.connection)?; let transaction = self.connection.transaction().map_err(sqlite_error)?; + let mut transaction_metrics = FinalizationTransactionMetricsV1::new(); + verify_staged_source_chain(&transaction, source, control)?; + let content_epoch = authenticated_authority_epoch(&transaction, source)?; + install_base_freeze(&transaction)?; store_finalization_state( &transaction, &PersistedFinalizationStateV1::new(content_epoch, source)?, )?; - transaction.commit().map_err(sqlite_error)?; + checkpoint(control)?; + commit_finalization_transaction(transaction, &mut transaction_metrics)?; + let step = CodeLexicalArtifactFinalizationStepV1::Pending { + phase: CodeLexicalArtifactFinalizationPhaseV1::IndexBuild, + completed_sections: 0, + completed_rows: 0, + }; + record_finalization_step(&step); + return Ok(step); } let transaction = self.connection.transaction().map_err(sqlite_error)?; + let mut transaction_metrics = FinalizationTransactionMetricsV1::new(); let mut state = load_finalization_state(&transaction)?.ok_or_else(|| { CodeLexicalArtifactErrorV1::Corrupt( "lexical artifact finalization marker disappeared".to_owned(), @@ -680,6 +1384,21 @@ impl CodeLexicalArtifactBuilderV1 { .to_owned(), )); } + if state.phase != PersistedFinalizationPhaseV1::Digest { + super::with_builder_sorter_cpu_admission(&transaction, || { + advance_pre_digest_work(&transaction, &mut state, control) + })??; + store_finalization_state(&transaction, &state)?; + checkpoint(control)?; + commit_finalization_transaction(transaction, &mut transaction_metrics)?; + let step = CodeLexicalArtifactFinalizationStepV1::Pending { + phase: state.phase.public(), + completed_sections: 0, + completed_rows: state.completed_rows, + }; + record_finalization_step(&step); + return Ok(step); + } let mut remaining_work = maximum_work; let section_count = u64::try_from(SECTION_NAMES.len()).map_err(contract_number)?; while remaining_work > 0 && state.section_ordinal < section_count { @@ -692,6 +1411,7 @@ impl CodeLexicalArtifactBuilderV1 { wake_metrics.probe(); let rows = advance_section_rows(&transaction, section, &mut state, remaining_work, control)?; + wake_metrics.add_rows(rows)?; if rows > 0 { remaining_work = remaining_work.checked_sub(rows).ok_or_else(|| { CodeLexicalArtifactErrorV1::Corrupt( @@ -702,34 +1422,32 @@ impl CodeLexicalArtifactBuilderV1 { } let section_digest = finish_persisted_section(section_name, &state)?; - match state.phase { - PersistedFinalizationPhaseV1::Build => { - state.completed_sections.push(section_digest) - } - PersistedFinalizationPhaseV1::Verify => { - let expected = - state - .completed_sections - .get(section_ordinal) - .ok_or_else(|| { - CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact verification has no matching build section" - .to_owned(), - ) - })?; - if §ion_digest != expected { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact changed between bounded finalization wakes" - .to_owned(), - )); - } - } + state.completed_sections.push(section_digest); + if section == FinalizationSectionV1::SourcePages { + let base_sections = finish_base_section_receipt_fold( + &state.base_section_row_counts, + &state.base_section_accumulators, + )?; + state.completed_rows = base_sections + .iter() + .try_fold(state.completed_rows, |total, section| { + total.checked_add(section.row_count) + }) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact adopted base-section row count overflowed".to_owned(), + ) + })?; + state.completed_sections.extend(base_sections); + state.section_ordinal = + u64::try_from(1 + BASE_SECTION_NAMES.len()).map_err(contract_number)?; + } else { + state.section_ordinal = state.section_ordinal.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact finalization section ordinal overflowed".to_owned(), + ) + })?; } - state.section_ordinal = state.section_ordinal.checked_add(1).ok_or_else(|| { - CodeLexicalArtifactErrorV1::Contract( - "lexical artifact finalization section ordinal overflowed".to_owned(), - ) - })?; state.section_row_count = 0; state.section_last_key = None; if state.section_ordinal < section_count { @@ -743,26 +1461,9 @@ impl CodeLexicalArtifactBuilderV1 { if state.section_ordinal < section_count { store_finalization_state(&transaction, &state)?; checkpoint(control)?; - transaction.commit().map_err(sqlite_error)?; - let step = CodeLexicalArtifactFinalizationStepV1::Pending { - completed_sections: u64::try_from(state.completed_sections.len()) - .map_err(contract_number)?, - completed_rows: state.completed_rows, - }; - record_finalization_step(&step); - return Ok(step); - } - - if state.phase == PersistedFinalizationPhaseV1::Build { - state.phase = PersistedFinalizationPhaseV1::Verify; - state.section_ordinal = 0; - state.section_row_count = 0; - state.section_last_key = None; - state.section_accumulator = initial_section_accumulator(SECTION_NAMES[0])?.to_vec(); - store_finalization_state(&transaction, &state)?; - checkpoint(control)?; - transaction.commit().map_err(sqlite_error)?; + commit_finalization_transaction(transaction, &mut transaction_metrics)?; let step = CodeLexicalArtifactFinalizationStepV1::Pending { + phase: state.phase.public(), completed_sections: u64::try_from(state.completed_sections.len()) .map_err(contract_number)?, completed_rows: state.completed_rows, @@ -811,7 +1512,7 @@ impl CodeLexicalArtifactBuilderV1 { .execute("DELETE FROM finalization_state WHERE singleton = 1", []) .map_err(sqlite_error)?; checkpoint(control)?; - transaction.commit().map_err(sqlite_error)?; + commit_finalization_transaction(transaction, &mut transaction_metrics)?; let step = CodeLexicalArtifactFinalizationStepV1::Ready(Box::new(receipt)); record_finalization_step(&step); Ok(step) @@ -931,6 +1632,11 @@ fn private_staging_error(error: std::io::Error) -> CodeLexicalArtifactErrorV1 { /// Amortized per-entry b-tree node overhead (headers and edge pointers) /// charged on top of each entry's key/value payload. const BTREE_MAP_ENTRY_OVERHEAD_BYTES: usize = 16; +const PERSISTED_CURSOR_DIGEST_FIELDS: usize = 4; +const PERSISTED_CURSOR_U64_FIELDS: usize = 9; +const MAX_DECIMAL_U64_BYTES: usize = 20; +const PERSISTED_CURSOR_JSON_DELIMITERS_BYTES: usize = 64; +const PREPARED_PAGE_DIGEST_FIELDS: usize = 3; /// Validate a caller-selected build memory budget and return the fixed /// ledger charge it must absorb before any page is admitted. @@ -1030,33 +1736,518 @@ fn metadata_retained_bytes(metadata: &CodeLexicalProjectionMetadataV1) -> usize }) } -/// Refuse a page whose ledger charge does not fit the remaining budget. -/// -/// Runs before the staging transaction, so a refusal never mutates staged -/// progress and an upstream caller can decline the page before advancing its -/// sealed source cursor. -fn admit_page_within_memory_budget( +fn page_batch_ledger_charge_bytes( + metadata: &CodeLexicalProjectionMetadataV1, + pages: &[VerifiedSealedLexicalPageV1], +) -> Result { + let retained = pages.iter().try_fold(0usize, |total, page| { + total + .checked_add(page.retained_owned_bytes()) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch retained-byte charge overflowed".to_owned(), + ) + }) + })?; + let prepared_retained = pages.iter().try_fold(0usize, |total, page| { + page_prepared_retained_upper_bound_bytes(metadata, page).and_then(|page_bound| { + total.checked_add(page_bound).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch prepared-retained charge overflowed".to_owned(), + ) + }) + }) + })?; + let active_workers = tracedecay_code_index::parallelism::indexing_workers().min(pages.len()); + let mut scratch = pages + .iter() + .map(|page| page_transient_peak_bytes(metadata, page, usize::MAX)) + .collect::, _>>()?; + scratch.sort_unstable_by(|left, right| right.cmp(left)); + let active_scratch = + scratch + .into_iter() + .take(active_workers) + .try_fold(0usize, |total, charge| { + total.checked_add(charge).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch preparation scratch charge overflowed".to_owned(), + ) + }) + })?; + retained + .checked_add(prepared_retained) + .and_then(|bytes| bytes.checked_add(active_scratch)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch ledger charge overflowed".to_owned(), + ) + }) +} + +#[derive(Default)] +struct CanonicalBatchLimitLedgerV1 { + estimated_rows: usize, + estimated_write_bytes: usize, +} + +#[derive(Clone, Copy)] +struct BatchLimitExceededV1 { + limit: CodeLexicalArtifactBatchLimitV1, + required: usize, + maximum: usize, +} + +impl CanonicalBatchLimitLedgerV1 { + fn try_admit( + &mut self, + page_rows: usize, + page_write_bytes: usize, + ) -> Result, CodeLexicalArtifactErrorV1> { + let estimated_rows = self.estimated_rows.checked_add(page_rows).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch row preflight overflowed".to_owned(), + ) + })?; + let estimated_write_bytes = self + .estimated_write_bytes + .checked_add(page_write_bytes) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch write preflight overflowed".to_owned(), + ) + })?; + if estimated_rows > CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1 { + return Ok(Some(BatchLimitExceededV1 { + limit: CodeLexicalArtifactBatchLimitV1::PreparedRows, + required: estimated_rows, + maximum: CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1, + })); + } + if estimated_write_bytes > CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1 { + return Ok(Some(BatchLimitExceededV1 { + limit: CodeLexicalArtifactBatchLimitV1::EstimatedWriteBytes, + required: estimated_write_bytes, + maximum: CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1, + })); + } + self.estimated_rows = estimated_rows; + self.estimated_write_bytes = estimated_write_bytes; + Ok(None) + } +} + +fn largest_exact_prepared_prefix( + pages: &[PreparedCodeLexicalArtifactPageV1], + fixed_ledger_charge_bytes: usize, + memory_budget_bytes: usize, +) -> Result<(usize, Option), CodeLexicalArtifactErrorV1> { + let mut ledger = CanonicalBatchLimitLedgerV1::default(); + for (index, page) in pages.iter().enumerate() { + if let Some(exceeded) = + ledger.try_admit(page.estimated_write_rows(), page.estimated_write_bytes())? + { + return Ok((index, Some(exceeded))); + } + let required = prepared_batch_memory_with_term_plan_required_bytes( + fixed_ledger_charge_bytes, + &pages[..=index], + )?; + if required > memory_budget_bytes { + return Ok(( + index, + Some(BatchLimitExceededV1 { + limit: CodeLexicalArtifactBatchLimitV1::Memory, + required, + maximum: memory_budget_bytes, + }), + )); + } + } + Ok((pages.len(), None)) +} + +/// Refuse a batch unless its retained source pages, all prepared outputs, and +/// one scratch peak per active worker fit together. Admission runs before the +/// staging transaction, so refusal leaves builder and source progress intact. +fn admit_page_batch_within_memory_budget( metadata: &CodeLexicalProjectionMetadataV1, fixed_ledger_charge_bytes: usize, memory_budget_bytes: usize, - page: &VerifiedSealedLexicalPageV1, + pages: &[VerifiedSealedLexicalPageV1], ) -> Result<(), CodeLexicalArtifactErrorV1> { - let refusal = |needed: usize| { - CodeLexicalArtifactErrorV1::Contract(format!( - "sealed lexical page needs at least {needed} ledger bytes on top of the {fixed_ledger_charge_bytes}-byte fixed charge, exceeding the {memory_budget_bytes}-byte build memory budget" + for page in pages { + if page.retained_owned_bytes() > CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 { + return Err(CodeLexicalArtifactErrorV1::Contract(format!( + "sealed lexical page retained bytes exceed the {}-byte artifact input bound", + CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 + ))); + } + } + let additional = page_batch_ledger_charge_bytes(metadata, pages)?; + let required = fixed_ledger_charge_bytes + .checked_add(additional) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch total ledger charge overflowed".to_owned(), + ) + })?; + if required > memory_budget_bytes { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::Memory, + required, + memory_budget_bytes, + )); + } + Ok(()) +} + +fn prepared_batch_memory_required_bytes( + fixed_ledger_charge_bytes: usize, + pages: &[PreparedCodeLexicalArtifactPageV1], +) -> Result { + let source_retained = pages.iter().try_fold(0usize, |total, page| { + total + .checked_add(page.source_retained_bytes()) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical batch source-retained charge overflowed".to_owned(), + ) + }) + })?; + let prepared_retained = pages.iter().try_fold(0usize, |total, page| { + total + .checked_add(page.retained_owned_bytes()) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical batch retained charge overflowed".to_owned(), + ) + }) + })?; + let active_workers = tracedecay_code_index::parallelism::indexing_workers().min(pages.len()); + let mut scratch = pages + .iter() + .map(PreparedCodeLexicalArtifactPageV1::preparation_scratch_bytes) + .collect::>(); + scratch.sort_unstable_by(|left, right| right.cmp(left)); + let active_scratch = scratch + .into_iter() + .take(active_workers) + .try_fold(0usize, |total, charge| total.checked_add(charge)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical batch active-worker scratch charge overflowed".to_owned(), + ) + })?; + fixed_ledger_charge_bytes + .checked_add(source_retained) + .and_then(|bytes| bytes.checked_add(prepared_retained)) + .and_then(|bytes| bytes.checked_add(active_scratch)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical batch total ledger charge overflowed".to_owned(), + ) + }) +} + +fn prepared_term_row_count( + pages: &[PreparedCodeLexicalArtifactPageV1], +) -> Result { + pages + .iter() + .flat_map(|page| &page.documents) + .try_fold(0usize, |rows, document| { + rows.checked_add(document.term_postings.len()) + .ok_or_else(batch_ledger_overflow) + }) +} + +fn term_insert_plan_ledger_bytes(term_rows: usize) -> Result { + let entries = term_rows + .checked_mul(TERM_INSERT_PLAN_BYTES_PER_REF) + .ok_or_else(batch_ledger_overflow)?; + let runs = term_rows.div_ceil(TERM_INSERT_SORT_RUN_ROWS); + let merge_heap = runs + .checked_mul(std::mem::size_of::>()) + .ok_or_else(batch_ledger_overflow)?; + entries + .checked_add(merge_heap) + .ok_or_else(batch_ledger_overflow) +} + +fn prepared_batch_memory_with_term_plan_required_bytes( + fixed_ledger_charge_bytes: usize, + pages: &[PreparedCodeLexicalArtifactPageV1], +) -> Result { + let base = prepared_batch_memory_required_bytes(fixed_ledger_charge_bytes, pages)?; + let plan = term_insert_plan_ledger_bytes(prepared_term_row_count(pages)?)?; + base.checked_add(plan).ok_or_else(batch_ledger_overflow) +} + +fn admit_prepared_page_batch( + fixed_ledger_charge_bytes: usize, + memory_budget_bytes: usize, + pages: &[PreparedCodeLexicalArtifactPageV1], +) -> Result<(), CodeLexicalArtifactErrorV1> { + let required = + prepared_batch_memory_with_term_plan_required_bytes(fixed_ledger_charge_bytes, pages)?; + if required > memory_budget_bytes { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::Memory, + required, + memory_budget_bytes, + )); + } + let estimated_rows = sum_prepared_metric( + pages, + PreparedCodeLexicalArtifactPageV1::estimated_write_rows, + "prepared lexical batch row estimate overflowed", + )?; + if estimated_rows > CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1 { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::PreparedRows, + estimated_rows, + CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1, + )); + } + let estimated_write_bytes = sum_prepared_metric( + pages, + PreparedCodeLexicalArtifactPageV1::estimated_write_bytes, + "prepared lexical batch write estimate overflowed", + )?; + if estimated_write_bytes > CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1 { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::EstimatedWriteBytes, + estimated_write_bytes, + CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1, + )); + } + Ok(()) +} + +fn prepare_term_insert_plan<'a>( + fixed_ledger_charge_bytes: usize, + memory_budget_bytes: usize, + pages: &'a [PreparedCodeLexicalArtifactPageV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result, CodeLexicalArtifactErrorV1> { + checkpoint(control)?; + let mut term_rows = 0usize; + for page in pages { + checkpoint(control)?; + for document in &page.documents { + checkpoint(control)?; + term_rows = term_rows + .checked_add(document.term_postings.len()) + .ok_or_else(batch_ledger_overflow)?; + if term_rows > CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1 { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::PreparedRows, + term_rows, + CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1, + )); + } + } + } + let plan_bytes = term_insert_plan_ledger_bytes(term_rows)?; + let required = prepared_batch_memory_required_bytes(fixed_ledger_charge_bytes, pages)? + .checked_add(plan_bytes) + .ok_or_else(batch_ledger_overflow)?; + if required > memory_budget_bytes { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::Memory, + required, + memory_budget_bytes, + )); + } + + let mut entries = Vec::new(); + entries.try_reserve_exact(term_rows).map_err(|error| { + CodeLexicalArtifactErrorV1::Io(format!( + "bounded lexical term insert plan allocation failed: {error}" + )) + })?; + for page in pages { + checkpoint(control)?; + for document in &page.documents { + checkpoint(control)?; + entries.extend( + document + .term_postings + .iter() + .map(|posting| PreparedTermInsertRefV1 { + document_id: document.document_id, + posting, + }), + ); + } + } + for run in entries.chunks_mut(TERM_INSERT_SORT_RUN_ROWS) { + checkpoint(control)?; + run.sort_unstable_by(|left, right| left.key().cmp(&right.key())); + checkpoint(control)?; + } + checkpoint(control)?; + + let run_count = term_rows.div_ceil(TERM_INSERT_SORT_RUN_ROWS); + let mut merge_heap = BinaryHeap::new(); + merge_heap.try_reserve_exact(run_count).map_err(|error| { + CodeLexicalArtifactErrorV1::Io(format!( + "bounded lexical term merge heap allocation failed: {error}" )) + })?; + for (run_index, run) in entries.chunks(TERM_INSERT_SORT_RUN_ROWS).enumerate() { + checkpoint(control)?; + let Some(entry) = run.first().copied() else { + continue; + }; + merge_heap.push(Reverse(PreparedTermMergeCursorV1 { + entry, + run_index, + run_offset: 0, + })); + } + Ok(PreparedTermInsertPlanV1 { + entries, + merge_heap, + }) +} + +fn next_term_insert<'a>( + plan: &mut PreparedTermInsertPlanV1<'a>, +) -> Result>, CodeLexicalArtifactErrorV1> { + let Some(Reverse(cursor)) = plan.merge_heap.pop() else { + return Ok(None); }; - let headroom = memory_budget_bytes.saturating_sub(fixed_ledger_charge_bytes); - let retained = page.retained_owned_bytes(); - if retained > headroom { - return Err(refusal(retained)); + let next_offset = cursor + .run_offset + .checked_add(1) + .ok_or_else(batch_ledger_overflow)?; + if next_offset < TERM_INSERT_SORT_RUN_ROWS { + let run_start = cursor + .run_index + .checked_mul(TERM_INSERT_SORT_RUN_ROWS) + .ok_or_else(batch_ledger_overflow)?; + let next_index = run_start + .checked_add(next_offset) + .ok_or_else(batch_ledger_overflow)?; + let run_end = run_start + .checked_add(TERM_INSERT_SORT_RUN_ROWS) + .ok_or_else(batch_ledger_overflow)? + .min(plan.entries.len()); + if next_index < run_end { + let entry = plan.entries.get(next_index).copied().ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical term merge cursor escaped its bounded run".to_owned(), + ) + })?; + plan.merge_heap.push(Reverse(PreparedTermMergeCursorV1 { + entry, + run_index: cursor.run_index, + run_offset: next_offset, + })); + } } - let transient_headroom = headroom - retained; - let transient = page_transient_peak_bytes(metadata, page, transient_headroom)?; - if transient > transient_headroom { - return Err(refusal(retained.saturating_add(transient))); + Ok(Some(cursor.entry)) +} + +fn sum_prepared_metric( + pages: &[PreparedCodeLexicalArtifactPageV1], + metric: impl Fn(&PreparedCodeLexicalArtifactPageV1) -> usize, + overflow: &str, +) -> Result { + pages.iter().try_fold(0usize, |total, page| { + total + .checked_add(metric(page)) + .ok_or_else(|| CodeLexicalArtifactErrorV1::Contract(overflow.to_owned())) + }) +} + +fn batch_limit( + limit: CodeLexicalArtifactBatchLimitV1, + required: usize, + maximum: usize, +) -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::BatchTooLarge { + limit, + required, + maximum, } - Ok(()) +} + +fn batch_ledger_overflow() -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch ledger charge overflowed".to_owned(), + ) +} + +fn prepare_page_batch_admission( + connection: &Connection, + metadata: &CodeLexicalProjectionMetadataV1, + fixed_ledger_charge_bytes: usize, + memory_budget_bytes: usize, + pages: &[VerifiedSealedLexicalPageV1], +) -> Result<(CodeLexicalArtifactBuildProgressV1, usize), CodeLexicalArtifactErrorV1> { + admit_page_batch_within_memory_budget( + metadata, + fixed_ledger_charge_bytes, + memory_budget_bytes, + pages, + )?; + let current = progress(connection)?; + let mut fresh_start = pages.len(); + let mut expected_fresh_ordinal = current.next_page_ordinal; + for (index, page) in pages.iter().enumerate() { + if let Some(previous_page) = index.checked_sub(1).and_then(|index| pages.get(index)) { + let expected = previous_page.page_ordinal().checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "sealed lexical page ordinal overflowed".to_owned(), + ) + })?; + if page.page_ordinal() != expected { + return Err(CodeLexicalArtifactErrorV1::Contract( + "sealed lexical page batches must be contiguous and ordered".to_owned(), + )); + } + } + let persisted_previous; + let previous = if index == 0 { + persisted_previous = cursor_before_page(connection, page.page_ordinal())?; + persisted_previous.as_ref() + } else { + pages + .get(index - 1) + .map(VerifiedSealedLexicalPageV1::next_cursor) + }; + page.verify_transition(previous) + .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; + if page.page_ordinal() < current.next_page_ordinal { + verify_replayed_page(connection, page)?; + continue; + } + if page.page_ordinal() != expected_fresh_ordinal { + return Err(CodeLexicalArtifactErrorV1::Contract( + "sealed lexical pages must be appended in exact ordinal order".to_owned(), + )); + } + if fresh_start == pages.len() { + fresh_start = index; + if let Some(cumulative) = ¤t.cumulative_source_digest + && page.page_ordinal() > 0 + && cumulative == page.cumulative_digest() + { + return Err(CodeLexicalArtifactErrorV1::Contract( + "sealed lexical page did not advance its cumulative digest".to_owned(), + )); + } + } + expected_fresh_ordinal = expected_fresh_ordinal.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "sealed lexical page ordinal overflowed".to_owned(), + ) + })?; + } + Ok((current, fresh_start)) } /// The widest transient upper bound one staged chunk or import can require. @@ -1084,6 +2275,144 @@ fn page_transient_peak_bytes( Ok(peak) } +/// Conservative output-plus-scratch upper bound before one page is prepared. +/// Every derived retained value coexists with that page's widest transient +/// record allocation. +fn page_preparation_upper_bound_bytes( + metadata: &CodeLexicalProjectionMetadataV1, + page: &VerifiedSealedLexicalPageV1, +) -> Result { + page_prepared_retained_upper_bound_bytes(metadata, page)? + .checked_add(page_transient_peak_bytes(metadata, page, usize::MAX)?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page preparation charge overflowed".to_owned(), + ) + }) +} + +/// Conservative retained output for one fully prepared page. Every record's +/// owned projection remains live until the ordered batch commits, while only +/// the widest per-worker scratch allocation is charged separately. +fn page_prepared_retained_upper_bound_bytes( + metadata: &CodeLexicalProjectionMetadataV1, + page: &VerifiedSealedLexicalPageV1, +) -> Result { + let chunk_bytes = page.chunks().iter().try_fold(0usize, |total, admitted| { + total + .checked_add(projected_chunk_prepared_retained_upper_bound_bytes( + metadata, admitted, + )?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page preparation charge overflowed".to_owned(), + ) + }) + })?; + let record_bytes = page + .imports() + .iter() + .try_fold(chunk_bytes, |total, evidence| { + total + .checked_add(import_transient_bytes(evidence)?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page preparation charge overflowed".to_owned(), + ) + }) + })?; + record_bytes + .checked_add(prepared_page_authority_upper_bound_bytes(page)?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page preparation charge overflowed".to_owned(), + ) + }) +} + +fn projected_chunk_prepared_retained_upper_bound_bytes( + metadata: &CodeLexicalProjectionMetadataV1, + admitted: &ExtractionAdmittedCodeSearchChunkV1, +) -> Result { + let transient = projected_chunk_transient_bytes(metadata, admitted)?; + let text_bytes = admitted.chunk().sanitized_text.as_str().len(); + let normalized_text_bytes = text_bytes; + let (_, normalized_scratch) = document_ngram_scratch(normalized_text_bytes)?; + let (_, raw_scratch) = document_ngram_scratch(text_bytes)?; + // Every authorized n-gram slot may become a distinct ordered-map key with + // its own Roaring container while already-encoded shards accumulate. + // The exact prepared ledger separately charges those encoded blobs. + let ngram_aggregation_bytes = normalized_scratch + .checked_add(raw_scratch) + .and_then(|bytes| bytes.checked_div(std::mem::size_of::())) + .and_then(|slots| slots.checked_mul(NGRAM_AGGREGATION_BYTES_PER_LOGICAL_POSTING_V1)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared n-gram aggregation charge overflowed".to_owned(), + ) + })?; + transient + .checked_add(ngram_aggregation_bytes) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared document charge overflowed".to_owned(), + ) + }) +} + +/// Page-level prepared ownership that is not attributable to one chunk or +/// import. Duplicating the source page's complete retained charge covers +/// vector capacities and typed identities; the explicit cursor envelope +/// covers both serialized cursor copies and their JSON framing without +/// allocating during admission. +fn prepared_page_authority_upper_bound_bytes( + page: &VerifiedSealedLexicalPageV1, +) -> Result { + let digest_bytes = page + .page_digest() + .as_str() + .len() + .max(page.cumulative_digest().as_str().len()) + .max(page.next_cursor().import_dictionary_digest().as_str().len()); + let numeric_bytes = PERSISTED_CURSOR_U64_FIELDS + .checked_mul(MAX_DECIMAL_U64_BYTES) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared cursor numeric authority overflowed".to_owned(), + ) + })?; + let cursor_bytes = digest_bytes + .checked_mul(PERSISTED_CURSOR_DIGEST_FIELDS) + .and_then(|bytes| bytes.checked_add(numeric_bytes)) + .and_then(|bytes| bytes.checked_add(PERSISTED_CURSOR_JSON_DELIMITERS_BYTES)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared cursor authority overflowed".to_owned(), + ) + })?; + let prepared_digest_bytes = digest_bytes + .checked_mul(PREPARED_PAGE_DIGEST_FIELDS) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared digest authority overflowed".to_owned(), + ) + })?; + let persisted_cursor_bytes = cursor_bytes.checked_mul(2).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared cursor copies overflowed".to_owned(), + ) + })?; + page.retained_owned_bytes() + .checked_add(std::mem::size_of::()) + .and_then(|bytes| bytes.checked_add(prepared_digest_bytes)) + .and_then(|bytes| bytes.checked_add(persisted_cursor_bytes)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared page authority overflowed".to_owned(), + ) + }) +} + /// Conservative transient bytes staging one admitted chunk may allocate. /// /// This is intentionally arithmetic-only: budget refusal must not clone, @@ -1106,9 +2435,10 @@ fn projected_chunk_transient_bytes( chunk.anchor.file_occurrence_id )) })?; - // Normalization can expand one Unicode scalar to at most three scalars; - // JSON can then escape each byte. This bound intentionally charges both - // cloned and moved ownership before append allocates either representation. + // Canonical lexical normalization is ASCII lowercasing, so it preserves + // the exact UTF-8 byte length. JSON escaping is charged separately below. + // The bound intentionally charges both cloned and moved ownership before + // append allocates either representation. let text_bytes = chunk.sanitized_text.as_str().len(); let subtoken_bytes = chunk .subtokens @@ -1117,12 +2447,12 @@ fn projected_chunk_transient_bytes( let exact_bytes = chunk.exact_terms.iter().fold(0usize, |total, term| { total.saturating_add(term.canonical_bytes().len()) }); - let normalized_text_bytes = text_bytes.saturating_mul(3); + let normalized_text_bytes = text_bytes; let field_text_bytes = normalized_text_bytes - .saturating_add(logical_path.len().saturating_mul(3)) - .saturating_add(subtoken_bytes.saturating_mul(3)) - .saturating_add(exact_bytes.saturating_mul(6)); - let field_entries = text_bytes + .saturating_add(logical_path.len()) + .saturating_add(subtoken_bytes) + .saturating_add(exact_bytes.saturating_mul(2)); + let field_entries = lexical_token_count(chunk.sanitized_text.as_str()) .saturating_add(1) .saturating_add(chunk.subtokens.len()) .saturating_add(chunk.exact_terms.len().saturating_mul(2)); @@ -1152,6 +2482,20 @@ fn projected_chunk_transient_bytes( .saturating_add(serialized_bytes)) } +fn lexical_token_count(value: &str) -> usize { + let mut count = 0usize; + let mut inside_token = false; + for character in value.chars() { + let accepted = + character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | ':' | '.' | '/'); + if accepted && !inside_token { + count = count.saturating_add(1); + } + inside_token = accepted; + } + count +} + fn chunk_owned_bytes(chunk: &CodeSearchChunkV1) -> usize { let subtoken_bytes = chunk.subtokens.iter().fold( chunk @@ -1227,24 +2571,216 @@ fn import_transient_bytes( .saturating_add(256)) } -fn insert_source_page( +fn validate_prepared_page_batch( + current: &CodeLexicalArtifactBuildProgressV1, + pages: &[PreparedCodeLexicalArtifactPageV1], +) -> Result<(), CodeLexicalArtifactErrorV1> { + let mut expected_ordinal = current.next_page_ordinal; + let mut expected_document = current.completed_chunks; + let mut expected_previous = current + .next_cursor + .as_ref() + .map(encode_cursor) + .transpose()?; + for page in pages { + if page.page_ordinal != expected_ordinal || page.previous_cursor != expected_previous { + return Err(CodeLexicalArtifactErrorV1::Contract( + "prepared lexical pages must continue the exact durable cursor in order".to_owned(), + )); + } + if usize::try_from(page.chunk_count).map_err(contract_number)? != page.documents.len() + || usize::try_from(page.import_count).map_err(contract_number)? != page.imports.len() + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "prepared lexical page cardinality disagrees with its source receipt".to_owned(), + )); + } + for document in &page.documents { + if u64::try_from(document.document_id).map_err(contract_number)? != expected_document { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "prepared lexical document ids are not contiguous".to_owned(), + )); + } + expected_document = expected_document.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical document count overflowed".to_owned(), + ) + })?; + } + let next_cursor = decode_cursor(&page.next_cursor)?; + if next_cursor.next_page_ordinal() + != expected_ordinal.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical page ordinal overflowed".to_owned(), + ) + })? + || next_cursor.emitted_chunks() != expected_document + || next_cursor.cumulative_digest() != &page.cumulative_digest + || next_cursor.import_dictionary_digest() != &page.import_dictionary_digest + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "prepared lexical page receipt disagrees with its exact next cursor".to_owned(), + )); + } + expected_ordinal = next_cursor.next_page_ordinal(); + expected_previous = Some(page.next_cursor.clone()); + } + Ok(()) +} + +fn append_prepared_imports( transaction: &Transaction<'_>, - page: &VerifiedSealedLexicalPageV1, + page: &PreparedCodeLexicalArtifactPageV1, + control: &dyn CodeIndexExecutionControlV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + for import in &page.imports { + checkpoint(control)?; + transaction + .execute( + "INSERT INTO import_evidence(canonical, evidence) VALUES (?1, ?1)", + params![import.canonical.as_slice()], + ) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + transaction + .execute( + "INSERT INTO import_integrity(canonical, digest) VALUES (?1, ?2)", + params![ + import.canonical.as_slice(), + import.integrity_digest.as_str() + ], + ) + .map_err(sqlite_error)?; + } + Ok(()) +} + +fn append_prepared_postings( + transaction: &Transaction<'_>, + pages: &[PreparedCodeLexicalArtifactPageV1], + term_insert_plan: &mut PreparedTermInsertPlanV1<'_>, + control: &dyn CodeIndexExecutionControlV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + let mut term_statement = transaction + .prepare_cached( + "INSERT INTO term_postings(field, term, document_id, frequency) VALUES (?1, ?2, ?3, ?4)", + ) + .map_err(sqlite_error)?; + let mut exact_statement = transaction + .prepare_cached( + "INSERT OR IGNORE INTO exact_postings(field, term, document_id) VALUES (?1, ?2, ?3)", + ) + .map_err(sqlite_error)?; + let mut ngram_statement = transaction + .prepare_cached( + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .map_err(sqlite_error)?; + let expected_term_rows = term_insert_plan.entries.len(); + let mut inserted_term_rows = 0usize; + while let Some(entry) = next_term_insert(term_insert_plan)? { + if inserted_term_rows.is_multiple_of(TERM_INSERT_CONTROL_INTERVAL) { + checkpoint(control)?; + } + term_statement + .execute(params![ + entry.posting.field.as_str(), + entry.posting.term.as_str(), + entry.document_id, + entry.posting.frequency + ]) + .map_err(sqlite_error)?; + inserted_term_rows = inserted_term_rows + .checked_add(1) + .ok_or_else(batch_ledger_overflow)?; + } + if inserted_term_rows != expected_term_rows { + return Err(CodeLexicalArtifactErrorV1::Contract( + "lexical term merge omitted planned postings".to_owned(), + )); + } + for page in pages { + for document in &page.documents { + checkpoint(control)?; + for (field, term) in &document.exact_postings { + exact_statement + .execute(params![ + field.as_str(), + term.as_slice(), + document.document_id + ]) + .map_err(sqlite_error)?; + } + } + for shard in &page.ngram_shards { + checkpoint(control)?; + ngram_statement + .execute(params![ + i64::try_from(page.page_ordinal).map_err(contract_number)?, + shard.kind, + shard.ngram, + shard.documents.as_slice(), + i64::try_from(shard.cardinality).map_err(contract_number)?, + ]) + .map_err(sqlite_error)?; + } + } + Ok(()) +} + +fn append_prepared_rows( + transaction: &Transaction<'_>, + pages: &[PreparedCodeLexicalArtifactPageV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + let mut row_statement = transaction + .prepare_cached("INSERT INTO rows(document_id, chunk_id, row) VALUES (?1, ?2, ?3)") + .map_err(sqlite_error)?; + let mut integrity_statement = transaction + .prepare_cached( + "INSERT INTO document_integrity(document_id, chunk_id, digest) VALUES (?1, ?2, ?3)", + ) + .map_err(sqlite_error)?; + for page in pages { + for document in &page.documents { + checkpoint(control)?; + row_statement + .execute(params![ + document.document_id, + document.chunk_id.as_str(), + document.row.as_slice() + ]) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + integrity_statement + .execute(params![ + document.document_id, + document.chunk_id.as_str(), + document.integrity_digest.as_str() + ]) + .map_err(sqlite_error)?; + } + } + Ok(()) +} + +fn insert_prepared_source_page( + transaction: &Transaction<'_>, + page: &PreparedCodeLexicalArtifactPageV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { - let cursor = encode_cursor(page.next_cursor())?; transaction .execute( - "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, base_sections_receipt, next_cursor) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ - i64::try_from(page.page_ordinal()).map_err(contract_number)?, - page.page_digest().as_str(), - page.cumulative_digest().as_str(), - i64::try_from(page.chunk_count()).map_err(contract_number)?, - i64::try_from(page.payload_bytes()).map_err(contract_number)?, - i64::try_from(page.import_count()).map_err(contract_number)?, - i64::try_from(page.import_payload_bytes()).map_err(contract_number)?, - page.next_cursor().import_dictionary_digest().as_str(), - cursor, + i64::try_from(page.page_ordinal).map_err(contract_number)?, + page.page_digest.as_str(), + page.cumulative_digest.as_str(), + i64::try_from(page.chunk_count).map_err(contract_number)?, + i64::try_from(page.payload_bytes).map_err(contract_number)?, + i64::try_from(page.import_count).map_err(contract_number)?, + i64::try_from(page.import_payload_bytes).map_err(contract_number)?, + page.import_dictionary_digest.as_str(), + page.ngram_digest.as_str(), + page.base_sections_receipt.as_slice(), + page.next_cursor.as_slice(), ], ) .map_err(sqlite_error)?; @@ -1302,14 +2838,16 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError import_count INTEGER NOT NULL, import_payload_bytes INTEGER NOT NULL, import_dictionary_digest TEXT NOT NULL, + ngram_digest TEXT NOT NULL, + base_sections_receipt BLOB NOT NULL, next_cursor BLOB NOT NULL ); -- Every derived document and import receives its digest in the - -- same transaction that admits it. Bounded finalization verifies - -- these receipts before it seals a self-contained artifact, so a - -- pre-seal mutation cannot attest itself without rereading source. + -- same private append transaction as its page-level base receipt. + -- External connections cannot invoke that mutation authority. CREATE TABLE document_integrity ( document_id INTEGER PRIMARY KEY, + chunk_id TEXT NOT NULL, digest TEXT NOT NULL ); CREATE TABLE import_integrity ( @@ -1322,7 +2860,7 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError ) WITHOUT ROWID; CREATE TABLE rows ( document_id INTEGER PRIMARY KEY, - chunk_id TEXT NOT NULL UNIQUE, + chunk_id TEXT NOT NULL, row BLOB NOT NULL ); CREATE TABLE term_postings ( @@ -1349,56 +2887,414 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError PRIMARY KEY(field, term, document_id) ) WITHOUT ROWID; CREATE TABLE ngram_postings ( + page_ordinal INTEGER NOT NULL, kind INTEGER NOT NULL, ngram INTEGER NOT NULL, - document_id INTEGER NOT NULL, - PRIMARY KEY(kind, ngram, document_id) + documents BLOB NOT NULL, + cardinality INTEGER NOT NULL CHECK(cardinality > 0), + PRIMARY KEY(page_ordinal, kind, ngram) ) WITHOUT ROWID; CREATE TABLE vocabulary (term TEXT PRIMARY KEY) WITHOUT ROWID; - CREATE INDEX term_postings_by_term ON term_postings(term, field, document_id); - CREATE INDEX term_postings_by_document ON term_postings(document_id, field, term, frequency); - CREATE INDEX term_postings_by_document_term ON term_postings(document_id, term, field, frequency); - CREATE INDEX term_stats_by_term ON term_stats(term, field); - CREATE INDEX exact_postings_by_document ON exact_postings(document_id, field, term); - CREATE INDEX ngram_postings_by_document ON ngram_postings(document_id, kind, ngram); CREATE TRIGGER content_epoch_source_pages_insert AFTER INSERT ON source_pages BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_source_pages_update AFTER UPDATE ON source_pages BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_source_pages_delete AFTER DELETE ON source_pages BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; CREATE TRIGGER content_epoch_document_integrity_insert AFTER INSERT ON document_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_document_integrity_update AFTER UPDATE ON document_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_document_integrity_delete AFTER DELETE ON document_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; CREATE TRIGGER content_epoch_import_integrity_insert AFTER INSERT ON import_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_import_integrity_update AFTER UPDATE ON import_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_import_integrity_delete AFTER DELETE ON import_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; CREATE TRIGGER content_epoch_import_evidence_insert AFTER INSERT ON import_evidence BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_import_evidence_update AFTER UPDATE ON import_evidence BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_import_evidence_delete AFTER DELETE ON import_evidence BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_rows_insert AFTER INSERT ON rows BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_rows_update AFTER UPDATE ON rows BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_rows_delete AFTER DELETE ON rows BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_postings_insert AFTER INSERT ON term_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_postings_update AFTER UPDATE ON term_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_postings_delete AFTER DELETE ON term_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_stats_insert AFTER INSERT ON term_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_stats_update AFTER UPDATE ON term_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_stats_delete AFTER DELETE ON term_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_field_stats_insert AFTER INSERT ON field_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_field_stats_update AFTER UPDATE ON field_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_field_stats_delete AFTER DELETE ON field_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_exact_postings_insert AFTER INSERT ON exact_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_exact_postings_update AFTER UPDATE ON exact_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_exact_postings_delete AFTER DELETE ON exact_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_ngram_postings_insert AFTER INSERT ON ngram_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_ngram_postings_update AFTER UPDATE ON ngram_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_ngram_postings_delete AFTER DELETE ON ngram_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_vocabulary_insert AFTER INSERT ON vocabulary BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_vocabulary_update AFTER UPDATE ON vocabulary BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_vocabulary_delete AFTER DELETE ON vocabulary BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; + CREATE TRIGGER immutable_source_pages_update BEFORE UPDATE ON source_pages BEGIN SELECT RAISE(ABORT, 'immutable lexical source pages'); END; + CREATE TRIGGER immutable_source_pages_delete BEFORE DELETE ON source_pages BEGIN SELECT RAISE(ABORT, 'immutable lexical source pages'); END; + CREATE TRIGGER immutable_document_integrity_update BEFORE UPDATE ON document_integrity BEGIN SELECT RAISE(ABORT, 'immutable lexical document integrity'); END; + CREATE TRIGGER immutable_document_integrity_delete BEFORE DELETE ON document_integrity BEGIN SELECT RAISE(ABORT, 'immutable lexical document integrity'); END; + CREATE TRIGGER immutable_import_integrity_update BEFORE UPDATE ON import_integrity BEGIN SELECT RAISE(ABORT, 'immutable lexical import integrity'); END; + CREATE TRIGGER immutable_import_integrity_delete BEFORE DELETE ON import_integrity BEGIN SELECT RAISE(ABORT, 'immutable lexical import integrity'); END; + CREATE TRIGGER immutable_import_evidence_update BEFORE UPDATE ON import_evidence BEGIN SELECT RAISE(ABORT, 'immutable lexical import evidence'); END; + CREATE TRIGGER immutable_import_evidence_delete BEFORE DELETE ON import_evidence BEGIN SELECT RAISE(ABORT, 'immutable lexical import evidence'); END; + CREATE TRIGGER immutable_ngram_postings_update BEFORE UPDATE ON ngram_postings BEGIN SELECT RAISE(ABORT, 'immutable lexical ngram postings'); END; + CREATE TRIGGER immutable_ngram_postings_delete BEFORE DELETE ON ngram_postings BEGIN SELECT RAISE(ABORT, 'immutable lexical ngram postings'); END; + CREATE TRIGGER builder_gate_source_pages_insert BEFORE INSERT ON source_pages WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_document_integrity_insert BEFORE INSERT ON document_integrity WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_import_integrity_insert BEFORE INSERT ON import_integrity WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_import_evidence_insert BEFORE INSERT ON import_evidence WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_rows_insert BEFORE INSERT ON rows WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_rows_update BEFORE UPDATE ON rows WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_rows_delete BEFORE DELETE ON rows WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_term_postings_insert BEFORE INSERT ON term_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_term_postings_update BEFORE UPDATE ON term_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_term_postings_delete BEFORE DELETE ON term_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_exact_postings_insert BEFORE INSERT ON exact_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_exact_postings_update BEFORE UPDATE ON exact_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_exact_postings_delete BEFORE DELETE ON exact_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_ngram_postings_insert BEFORE INSERT ON ngram_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; ", ) .map_err(sqlite_error) } +fn verify_builder_mutation_gate_schema( + connection: &Connection, +) -> Result<(), CodeLexicalArtifactErrorV1> { + for (name, table, operation) in BUILDER_GATE_TRIGGER_LAYOUT { + let expected = format!( + "CREATE TRIGGER {name} BEFORE {operation} ON {table} WHEN {BUILDER_MUTATION_GATE_FUNCTION}() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END" + ); + verify_trigger_schema(connection, name, table, &expected)?; + } + for (name, table, operation, message) in IMMUTABLE_TRIGGER_LAYOUT { + let expected = format!( + "CREATE TRIGGER {name} BEFORE {operation} ON {table} BEGIN SELECT RAISE(ABORT, '{message}'); END" + ); + verify_trigger_schema(connection, name, table, &expected)?; + } + Ok(()) +} + +fn verify_trigger_schema( + connection: &Connection, + name: &str, + expected_table: &str, + expected_sql: &str, +) -> Result<(), CodeLexicalArtifactErrorV1> { + let stored: Option<(String, String)> = connection + .query_row( + "SELECT tbl_name, sql FROM sqlite_schema WHERE type = 'trigger' AND name = ?1", + [name], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(sqlite_corrupt)?; + if stored + .as_ref() + .map(|(table, sql)| (table.as_str(), sql.as_str())) + != Some((expected_table, expected_sql)) + { + return Err(CodeLexicalArtifactErrorV1::Corrupt(format!( + "lexical artifact private builder trigger {name} is missing or malformed" + ))); + } + Ok(()) +} + +fn install_base_freeze(transaction: &Transaction<'_>) -> Result<(), CodeLexicalArtifactErrorV1> { + transaction + .execute_batch( + " + CREATE TRIGGER frozen_source_pages_insert BEFORE INSERT ON source_pages BEGIN SELECT RAISE(ABORT, 'frozen lexical source pages'); END; + CREATE TRIGGER frozen_document_integrity_insert BEFORE INSERT ON document_integrity BEGIN SELECT RAISE(ABORT, 'frozen lexical document integrity'); END; + CREATE TRIGGER frozen_import_integrity_insert BEFORE INSERT ON import_integrity BEGIN SELECT RAISE(ABORT, 'frozen lexical import integrity'); END; + CREATE TRIGGER frozen_import_evidence_insert BEFORE INSERT ON import_evidence BEGIN SELECT RAISE(ABORT, 'frozen lexical import evidence'); END; + CREATE TRIGGER frozen_rows_insert BEFORE INSERT ON rows BEGIN SELECT RAISE(ABORT, 'frozen lexical rows'); END; + CREATE TRIGGER frozen_rows_update BEFORE UPDATE ON rows BEGIN SELECT RAISE(ABORT, 'frozen lexical rows'); END; + CREATE TRIGGER frozen_rows_delete BEFORE DELETE ON rows BEGIN SELECT RAISE(ABORT, 'frozen lexical rows'); END; + CREATE TRIGGER frozen_term_postings_insert BEFORE INSERT ON term_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical term postings'); END; + CREATE TRIGGER frozen_term_postings_update BEFORE UPDATE ON term_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical term postings'); END; + CREATE TRIGGER frozen_term_postings_delete BEFORE DELETE ON term_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical term postings'); END; + CREATE TRIGGER frozen_exact_postings_insert BEFORE INSERT ON exact_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical exact postings'); END; + CREATE TRIGGER frozen_exact_postings_update BEFORE UPDATE ON exact_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical exact postings'); END; + CREATE TRIGGER frozen_exact_postings_delete BEFORE DELETE ON exact_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical exact postings'); END; + CREATE TRIGGER frozen_ngram_postings_insert BEFORE INSERT ON ngram_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical ngram postings'); END; + CREATE TRIGGER frozen_ngram_postings_update BEFORE UPDATE ON ngram_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical ngram postings'); END; + CREATE TRIGGER frozen_ngram_postings_delete BEFORE DELETE ON ngram_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical ngram postings'); END; + ", + ) + .map_err(sqlite_error) +} + +fn authenticated_authority_epoch( + transaction: &Transaction<'_>, + source: &VerifiedSealedLexicalSourceReceiptV1, +) -> Result { + verify_builder_mutation_gate_schema(transaction)?; + let (pages, documents, import_integrity, import_evidence): (i64, i64, i64, i64) = transaction + .query_row( + "SELECT (SELECT COUNT(*) FROM source_pages), (SELECT COUNT(*) FROM document_integrity), (SELECT COUNT(*) FROM import_integrity), (SELECT COUNT(*) FROM import_evidence)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .map_err(sqlite_error)?; + let expected_epoch = pages + .checked_add(documents) + .and_then(|count| count.checked_add(import_integrity)) + .and_then(|count| count.checked_add(import_evidence)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact authority row count overflowed".to_owned(), + ) + })?; + let actual_epoch = content_epoch(transaction)?; + if actual_epoch != expected_epoch + || u64::try_from(pages).ok() != Some(source.page_count()) + || u64::try_from(documents).ok() != Some(source.total_chunks()) + || u64::try_from(import_integrity).ok() != Some(source.total_imports()) + || import_integrity != import_evidence + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact authenticated authority disagrees with its source receipt".to_owned(), + )); + } + Ok(actual_epoch) +} + +fn advance_pre_digest_work( + transaction: &Transaction<'_>, + state: &mut PersistedFinalizationStateV1, + control: &dyn CodeIndexExecutionControlV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + checkpoint(control)?; + match state.phase { + PersistedFinalizationPhaseV1::Statistics => { + with_cancellable_sqlite_statement(transaction, control, || { + derive_statistics_step(transaction, state.section_ordinal)?; + Ok(()) + })?; + state.section_ordinal = state.section_ordinal.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact statistics step overflowed".to_owned(), + ) + })?; + if state.section_ordinal == 3 { + state.phase = PersistedFinalizationPhaseV1::Indexes; + state.section_ordinal = 0; + } + } + PersistedFinalizationPhaseV1::Indexes => { + with_cancellable_sqlite_statement(transaction, control, || { + build_serving_index_step(transaction, state.section_ordinal)?; + Ok(()) + })?; + state.section_ordinal = state.section_ordinal.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact serving-index step overflowed".to_owned(), + ) + })?; + if state.section_ordinal == 7 { + verify_required_artifact_indexes(transaction)?; + state.phase = PersistedFinalizationPhaseV1::Digest; + state.section_ordinal = 0; + state.section_accumulator = initial_section_accumulator(SECTION_NAMES[0])?.to_vec(); + } + } + PersistedFinalizationPhaseV1::Digest => { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact selected pre-digest work after entering digest verification" + .to_owned(), + )); + } + } + checkpoint(control)?; + Ok(()) +} + +fn with_cancellable_sqlite_statement( + transaction: &Transaction<'_>, + control: &dyn CodeIndexExecutionControlV1, + operation: impl FnOnce() -> Result, +) -> Result { + checkpoint(control)?; + let interruption = Arc::new(AtomicU8::new(0)); + let finished = Arc::new(AtomicBool::new(false)); + let progress_interruption = Arc::clone(&interruption); + transaction + .progress_handler( + FINALIZATION_PROGRESS_INTERVAL_OPS, + Some(move || progress_interruption.load(Ordering::Acquire) != 0), + ) + .map_err(sqlite_error)?; + + let monitored = std::thread::scope(|scope| { + let monitor_interruption = Arc::clone(&interruption); + let monitor_finished = Arc::clone(&finished); + let (ready_sender, ready_receiver) = std::sync::mpsc::sync_channel(0); + let monitor = spawn_finalization_control_monitor(scope, move || { + let mut ready = false; + loop { + let reason = if control.is_cancelled() { + 1 + } else if control.is_deadline_exceeded() { + 2 + } else { + 0 + }; + if reason != 0 { + monitor_interruption.store(reason, Ordering::Release); + } + if !ready { + let _ = ready_sender.send(()); + ready = true; + } + if reason != 0 || monitor_finished.load(Ordering::Acquire) { + break; + } + std::thread::sleep(FINALIZATION_CONTROL_POLL_INTERVAL); + } + })?; + let readiness = ready_receiver.recv(); + let outcome = readiness + .as_ref() + .ok() + .map(|_| catch_unwind(AssertUnwindSafe(operation))); + finished.store(true, Ordering::Release); + Ok::<_, std::io::Error>((readiness, outcome, monitor.join())) + }); + let clear = transaction + .progress_handler(FINALIZATION_PROGRESS_INTERVAL_OPS, None:: bool>) + .map_err(sqlite_error); + clear?; + + let (readiness, outcome, monitor) = monitored.map_err(|error| { + CodeLexicalArtifactErrorV1::Io(format!( + "lexical artifact finalization cancellation monitor could not start: {error}" + )) + })?; + if let Err(payload) = monitor { + return Err(CodeLexicalArtifactErrorV1::Io( + tracedecay_code_index::parallelism::CodeIndexParallelismErrorV1::from_panic_payload( + 0, &*payload, + ) + .to_string(), + )); + } + readiness.map_err(|error| { + CodeLexicalArtifactErrorV1::Io(format!( + "lexical artifact finalization cancellation monitor stopped before readiness: {error}" + )) + })?; + + let outcome = match outcome.ok_or_else(|| { + CodeLexicalArtifactErrorV1::Io( + "lexical artifact finalization cancellation monitor produced no operation outcome" + .to_owned(), + ) + })? { + Ok(outcome) => outcome, + Err(payload) => resume_unwind(payload), + }; + match interruption.load(Ordering::Acquire) { + 1 => Err(CodeLexicalArtifactErrorV1::Interrupted( + tracedecay_code_index::production::CodeIndexInterruptionV1::Cancelled, + )), + 2 => Err(CodeLexicalArtifactErrorV1::Interrupted( + tracedecay_code_index::production::CodeIndexInterruptionV1::DeadlineExceeded, + )), + _ => outcome, + } +} + +fn spawn_finalization_control_monitor<'scope, 'environment>( + scope: &'scope std::thread::Scope<'scope, 'environment>, + monitor: impl FnOnce() + Send + 'scope, +) -> std::io::Result> +where + 'environment: 'scope, +{ + #[cfg(test)] + if FAIL_NEXT_FINALIZATION_MONITOR_SPAWN.with(std::cell::Cell::take) { + return Err(std::io::Error::other( + "injected finalization monitor spawn failure", + )); + } + std::thread::Builder::new() + .name("tracedecay-lexical-finalization-control".to_owned()) + .spawn_scoped(scope, monitor) +} + +fn derive_statistics_step( + transaction: &Transaction<'_>, + ordinal: u64, +) -> Result<(), CodeLexicalArtifactErrorV1> { + match ordinal { + 0 => hotpath::measure_block!("query.artifact.finalization.derive_field_stats", { + transaction.execute_batch( + "INSERT INTO field_stats(field, total_length) SELECT field, SUM(frequency) FROM term_postings GROUP BY field; + CREATE TRIGGER frozen_field_stats_insert BEFORE INSERT ON field_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical field statistics'); END; + CREATE TRIGGER frozen_field_stats_update BEFORE UPDATE ON field_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical field statistics'); END; + CREATE TRIGGER frozen_field_stats_delete BEFORE DELETE ON field_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical field statistics'); END;", + ) + }), + 1 => hotpath::measure_block!("query.artifact.finalization.derive_term_stats", { + transaction.execute_batch( + "INSERT INTO term_stats(field, term, document_frequency) SELECT field, term, COUNT(*) FROM term_postings GROUP BY field, term; + CREATE TRIGGER frozen_term_stats_insert BEFORE INSERT ON term_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical term statistics'); END; + CREATE TRIGGER frozen_term_stats_update BEFORE UPDATE ON term_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical term statistics'); END; + CREATE TRIGGER frozen_term_stats_delete BEFORE DELETE ON term_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical term statistics'); END;", + ) + }), + 2 => hotpath::measure_block!("query.artifact.finalization.derive_vocabulary", { + let subtoken = encode_field(LexicalFieldV1::Subtoken)?; + transaction + .execute( + "INSERT INTO vocabulary(term) SELECT DISTINCT term FROM term_postings WHERE field != ?1", + [subtoken], + ) + .and_then(|_| { + transaction.execute_batch( + "CREATE TRIGGER frozen_vocabulary_insert BEFORE INSERT ON vocabulary BEGIN SELECT RAISE(ABORT, 'frozen lexical vocabulary'); END; + CREATE TRIGGER frozen_vocabulary_update BEFORE UPDATE ON vocabulary BEGIN SELECT RAISE(ABORT, 'frozen lexical vocabulary'); END; + CREATE TRIGGER frozen_vocabulary_delete BEFORE DELETE ON vocabulary BEGIN SELECT RAISE(ABORT, 'frozen lexical vocabulary'); END;", + ) + }) + }), + _ => { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact selected an unknown statistics step".to_owned(), + )); + } + } + .map_err(sqlite_error)?; + Ok(()) +} + +fn build_serving_index_step( + transaction: &Transaction<'_>, + ordinal: u64, +) -> Result<(), CodeLexicalArtifactErrorV1> { + match ordinal { + 0 => hotpath::measure_block!( + "query.artifact.finalization.index.rows_by_chunk", + transaction.execute_batch("CREATE UNIQUE INDEX rows_by_chunk ON rows(chunk_id)") + ), + 1 => hotpath::measure_block!( + "query.artifact.finalization.index.term_postings_by_term", + transaction.execute_batch( + "CREATE INDEX term_postings_by_term ON term_postings(term, field, document_id)", + ) + ), + 2 => hotpath::measure_block!( + "query.artifact.finalization.index.term_postings_by_document", + transaction.execute_batch( + "CREATE INDEX term_postings_by_document ON term_postings(document_id, field, term, frequency)", + ) + ), + 3 => hotpath::measure_block!( + "query.artifact.finalization.index.term_postings_by_document_term", + transaction.execute_batch( + "CREATE INDEX term_postings_by_document_term ON term_postings(document_id, term, field, frequency)", + ) + ), + 4 => hotpath::measure_block!( + "query.artifact.finalization.index.term_stats_by_term", + transaction.execute_batch( + "CREATE INDEX term_stats_by_term ON term_stats(term, field)", + ) + ), + 5 => hotpath::measure_block!( + "query.artifact.finalization.index.exact_postings_by_document", + transaction.execute_batch( + "CREATE INDEX exact_postings_by_document ON exact_postings(document_id, field, term)", + ) + ), + 6 => hotpath::measure_block!( + "query.artifact.finalization.index.ngram_postings_by_ngram", + transaction.execute_batch( + "CREATE UNIQUE INDEX ngram_postings_by_ngram ON ngram_postings(kind, ngram, page_ordinal)", + ) + ), + _ => { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact selected an unknown serving-index step".to_owned(), + )); + } + } + .map_err(sqlite_error) +} + impl PersistedFinalizationStateV1 { fn new( content_epoch: i64, @@ -1409,12 +3305,16 @@ impl PersistedFinalizationStateV1 { "lexical artifact mutation epoch is negative".to_owned(), )); } + let (base_section_row_counts, base_section_accumulators) = + initial_base_section_receipt_fold()?; Ok(Self { - phase: PersistedFinalizationPhaseV1::Build, + phase: PersistedFinalizationPhaseV1::Statistics, section_ordinal: 0, section_row_count: 0, section_last_key: None, section_accumulator: initial_section_accumulator(SECTION_NAMES[0])?.to_vec(), + base_section_row_counts, + base_section_accumulators, completed_sections: Vec::new(), completed_rows: 0, content_epoch, @@ -1533,16 +3433,30 @@ fn validate_finalization_state( state: &PersistedFinalizationStateV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { let section_count = u64::try_from(SECTION_NAMES.len()).map_err(contract_number)?; - let completed_section_count = match state.phase { - PersistedFinalizationPhaseV1::Build => { - usize::try_from(state.section_ordinal).map_err(contract_number)? - } - PersistedFinalizationPhaseV1::Verify => SECTION_NAMES.len(), + let completed_section_count = if state.phase == PersistedFinalizationPhaseV1::Digest { + usize::try_from(state.section_ordinal).map_err(contract_number)? + } else { + 0 }; - if state.section_ordinal > section_count + let maximum_ordinal = match state.phase { + PersistedFinalizationPhaseV1::Statistics => 3, + PersistedFinalizationPhaseV1::Indexes => 7, + PersistedFinalizationPhaseV1::Digest => section_count, + }; + if state.section_ordinal > maximum_ordinal + || (state.phase == PersistedFinalizationPhaseV1::Digest + && state.section_ordinal > 0 + && state.section_ordinal + < u64::try_from(1 + BASE_SECTION_NAMES.len()).map_err(contract_number)?) || state.completed_sections.len() != completed_section_count || state.completed_sections.len() > SECTION_NAMES.len() || state.section_accumulator.len() != 32 + || state.base_section_row_counts.len() != BASE_SECTION_NAMES.len() + || state.base_section_accumulators.len() != BASE_SECTION_NAMES.len() + || state + .base_section_accumulators + .iter() + .any(|accumulator| accumulator.len() != 32) || state.content_epoch < 0 { return Err(CodeLexicalArtifactErrorV1::Corrupt( @@ -1560,6 +3474,11 @@ fn validate_finalization_state( )); } if let Some(key) = &state.section_last_key { + if state.phase != PersistedFinalizationPhaseV1::Digest { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "persisted lexical artifact pre-digest state has a row key".to_owned(), + )); + } let section_ordinal = usize::try_from(state.section_ordinal).map_err(contract_number)?; let section = FinalizationSectionV1::from_ordinal(section_ordinal)?; if !key.matches_section(section) { @@ -1670,15 +3589,15 @@ fn advance_section_rows( ( FinalizationSectionV1::NgramPostings, Some(PersistedFinalizationKeyV1::IntegerIntegerInteger { + page_ordinal, kind, ngram, - document_id, }), ) => advance_native_section_rows( transaction, section, section.seek_query(true), - params![kind, ngram, document_id, limit], + params![page_ordinal, kind, ngram, limit], state, control, ), @@ -1723,9 +3642,7 @@ fn advance_native_section_rows( let mut rows = statement.query(parameters).map_err(sqlite_error)?; let mut advanced = 0usize; while let Some(row) = rows.next().map_err(sqlite_error)? { - // Count the row before cancellation so rolled-back and interrupted - // work remains visible rather than masquerading as an idle wake. - record_finalization_row(); + // Cancellation remains bounded within every native-key scan. checkpoint(control)?; let key = native_row_key(section, row)?; if state @@ -1737,11 +3654,20 @@ fn advance_native_section_rows( "lexical artifact finalization keyset did not advance".to_owned(), )); } - if matches!( - section, - FinalizationSectionV1::DocumentIntegrity | FinalizationSectionV1::ImportIntegrity - ) { - verify_integrity_row(transaction, section, row, control)?; + if section == FinalizationSectionV1::SourcePages { + let page_ordinal = + u64::try_from(row.get::<_, i64>(0).map_err(sqlite_error)?).map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt has a negative page".to_owned(), + ) + })?; + let receipt: Vec = row.get(9).map_err(sqlite_error)?; + absorb_page_base_sections_receipt( + page_ordinal, + &receipt, + &mut state.base_section_row_counts, + &mut state.base_section_accumulators, + )?; } absorb_section_row( section.name(), @@ -1795,9 +3721,9 @@ fn native_row_key( }), FinalizationSectionV1::NgramPostings => { Ok(PersistedFinalizationKeyV1::IntegerIntegerInteger { - kind: row.get(0).map_err(sqlite_error)?, - ngram: row.get(1).map_err(sqlite_error)?, - document_id: row.get(2).map_err(sqlite_error)?, + page_ordinal: row.get(0).map_err(sqlite_error)?, + kind: row.get(1).map_err(sqlite_error)?, + ngram: row.get(2).map_err(sqlite_error)?, }) } FinalizationSectionV1::FieldStatistics | FinalizationSectionV1::Vocabulary => Ok( @@ -1810,61 +3736,6 @@ fn native_row_key( } } -fn verify_integrity_row( - transaction: &Transaction<'_>, - section: FinalizationSectionV1, - row: &rusqlite::Row<'_>, - control: &dyn CodeIndexExecutionControlV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { - let expected: String = row.get(1).map_err(sqlite_error)?; - let actual = match section { - FinalizationSectionV1::DocumentIntegrity => match row.get_ref(0).map_err(sqlite_error)? { - ValueRef::Integer(document) if document >= 0 => { - document_integrity_digest(transaction, document, control)? - } - _ => { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact document integrity receipt has an invalid key".to_owned(), - )); - } - }, - FinalizationSectionV1::ImportIntegrity => match row.get_ref(0).map_err(sqlite_error)? { - ValueRef::Blob(canonical) => { - let evidence: Option> = transaction - .query_row( - "SELECT evidence FROM import_evidence WHERE canonical = ?1", - [canonical], - |row| row.get(0), - ) - .optional() - .map_err(sqlite_error)?; - let evidence = evidence.ok_or_else(|| { - CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact import integrity receipt has no evidence".to_owned(), - ) - })?; - import_integrity_digest(canonical, &evidence)? - } - _ => { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact import integrity receipt has an invalid key".to_owned(), - )); - } - }, - _ => { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact integrity verification selected the wrong section".to_owned(), - )); - } - }; - if actual.as_str() != expected { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact derived content differs from its append receipt".to_owned(), - )); - } - Ok(()) -} - fn initial_section_accumulator(name: &str) -> Result<[u8; 32], CodeLexicalArtifactErrorV1> { let mut hasher = Sha256::new(); hasher.update(b"tracedecay.code-lexical-artifact-section.v2\0initial"); @@ -1946,373 +3817,122 @@ fn finish_section( /// counting/replaying every staged page on each bounded finalization wake. /// The final section receipts validate the full source-page cardinality before /// a sealed artifact is published. -fn verify_staged_source_tail( +fn verify_staged_source_chain( connection: &Connection, source: &VerifiedSealedLexicalSourceReceiptV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { - let cursor = match source.page_count().checked_sub(1) { - Some(page_ordinal) => connection - .query_row( - "SELECT next_cursor FROM source_pages WHERE page_ordinal = ?1", - [i64::try_from(page_ordinal).map_err(contract_number)?], - |row| row.get::<_, Vec>(0), - ) - .optional() - .map_err(sqlite_error)? - .as_deref() - .map(decode_cursor) - .transpose()?, - None => None, - }; - source - .verify_completion(cursor.as_ref()) - .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; - Ok(()) -} - -fn verify_sealed_receipt_header( - receipt: &VerifiedCodeLexicalArtifactV1, - expected_metadata_digest: &ManifestDigest, - source: &VerifiedSealedLexicalSourceReceiptV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { - verify_source_receipt(receipt, source)?; - if receipt.metadata_digest() != expected_metadata_digest { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "finalized lexical artifact metadata digest changed".to_owned(), - )); - } - Ok(()) -} - -fn verify_final_sections_against_source( - sections: &[CodeLexicalArtifactSectionDigestV1], - source: &VerifiedSealedLexicalSourceReceiptV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { - let expected = [ - ("source_pages", source.page_count()), - ("document_integrity", source.total_chunks()), - ("import_integrity", source.total_imports()), - ("import_evidence", source.total_imports()), - ("rows", source.total_chunks()), - ]; - for (name, expected_rows) in expected { - let actual = sections - .iter() - .find(|section| section.name == name) - .ok_or_else(|| { - CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact finalization omitted a required section".to_owned(), - ) - })?; - if actual.row_count != expected_rows { - return Err(CodeLexicalArtifactErrorV1::Corrupt(format!( - "lexical artifact {name} rows disagree with the sealed source receipt" - ))); - } - } - Ok(()) -} - -fn append_imports( - transaction: &Transaction<'_>, - page: &VerifiedSealedLexicalPageV1, - control: &dyn CodeIndexExecutionControlV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { - for evidence in page.imports() { - checkpoint(control)?; - let canonical = serde_json::to_vec(evidence) - .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; - transaction - .execute( - "INSERT INTO import_evidence(canonical, evidence) VALUES (?1, ?1)", - params![canonical], - ) - .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; - let digest = import_integrity_digest(&canonical, &canonical)?; - transaction - .execute( - "INSERT INTO import_integrity(canonical, digest) VALUES (?1, ?2)", - params![canonical, digest.as_str()], - ) - .map_err(sqlite_error)?; - } - Ok(()) -} - -fn append_page_rows( - transaction: &Transaction<'_>, - metadata: &CodeLexicalProjectionMetadataV1, - first_document: u64, - page: &VerifiedSealedLexicalPageV1, control: &dyn CodeIndexExecutionControlV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { - // The verified persisted cursor is the exact row-count authority. Using - // it avoids a full table COUNT on every page while preserving contiguous - // document IDs across restarts and replay. - let mut document = i64::try_from(first_document).map_err(contract_number)?; - for admitted in page.chunks() { - checkpoint(control)?; - u32::try_from(document).map_err(|_| { - CodeLexicalArtifactErrorV1::Contract( - "lexical artifact exceeds the posting document-id range".to_owned(), - ) - })?; - let chunk = admitted.chunk(); - if chunk.anchor.generation_id != metadata.generation { - return Err(CodeLexicalArtifactErrorV1::Contract( - "sealed lexical page contains a foreign generation".to_owned(), - )); - } - let logical_path = metadata - .logical_paths - .get(&chunk.anchor.file_occurrence_id) - .cloned() - .ok_or_else(|| { - CodeLexicalArtifactErrorV1::Contract(format!( - "lexical artifact metadata is missing path {}", - chunk.anchor.file_occurrence_id - )) - })?; - let (row, fields) = ProjectedChunkV1::from_ref(chunk, logical_path); - insert_fields(transaction, document, &fields)?; - insert_exact(transaction, document, &row)?; - insert_document_ngrams( - transaction, - NGRAM_NORMALIZED, - document, - row.normalized_text.as_bytes(), - control, - )?; - if row.sanitized_text.as_str().as_bytes() != row.normalized_text.as_bytes() { - insert_document_ngrams( - transaction, - NGRAM_RAW_OVERRIDE, - document, - row.sanitized_text.as_str().as_bytes(), - control, - )?; - } - let artifact_row = ArtifactRowV1::from(row); - let bytes = serde_json::to_vec(&artifact_row) - .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; - transaction - .execute( - "INSERT INTO rows(document_id, chunk_id, row) VALUES (?1, ?2, ?3)", - params![document, artifact_row.id.as_str(), bytes], - ) - .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; - let digest = document_integrity_digest(transaction, document, control)?; - transaction - .execute( - "INSERT INTO document_integrity(document_id, digest) VALUES (?1, ?2)", - params![document, digest.as_str()], - ) - .map_err(sqlite_error)?; - document = document.checked_add(1).ok_or_else(|| { - CodeLexicalArtifactErrorV1::Contract( - "lexical artifact document id overflowed".to_owned(), - ) - })?; - } - Ok(()) -} - -fn insert_fields( - transaction: &Transaction<'_>, - document: i64, - fields: &BTreeMap>, -) -> Result<(), CodeLexicalArtifactErrorV1> { - let mut field_stats = transaction - .prepare_cached( - "INSERT INTO field_stats(field, total_length) VALUES (?1, ?2) ON CONFLICT(field) DO UPDATE SET total_length = total_length + excluded.total_length", - ) - .map_err(sqlite_error)?; - let mut term_postings = transaction - .prepare_cached( - "INSERT INTO term_postings(field, term, document_id, frequency) VALUES (?1, ?2, ?3, ?4)", - ) - .map_err(sqlite_error)?; - let mut term_stats = transaction - .prepare_cached( - "INSERT INTO term_stats(field, term, document_frequency) VALUES (?1, ?2, 1) ON CONFLICT(field, term) DO UPDATE SET document_frequency = document_frequency + 1", + let mut statement = connection + .prepare( + "SELECT page_ordinal, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor FROM source_pages ORDER BY page_ordinal", ) .map_err(sqlite_error)?; - let mut vocabulary = transaction - .prepare_cached("INSERT OR IGNORE INTO vocabulary(term) VALUES (?1)") - .map_err(sqlite_error)?; - for (field, terms) in fields { - let encoded_field = encode_field(*field)?; - field_stats - .execute(params![ - encoded_field, - i64::try_from(terms.len()).map_err(contract_number)? - ]) - .map_err(sqlite_error)?; - let mut frequencies = BTreeMap::<&str, u32>::new(); - for term in terms { - frequencies - .entry(term) - .and_modify(|frequency| *frequency = frequency.saturating_add(1)) - .or_insert(1); - } - for (term, frequency) in frequencies { - term_postings - .execute(params![encoded_field, term, document, i64::from(frequency)]) - .map_err(sqlite_error)?; - term_stats - .execute(params![encoded_field, term]) - .map_err(sqlite_error)?; - if *field != LexicalFieldV1::Subtoken { - vocabulary.execute([term]).map_err(sqlite_error)?; - } + let mut rows = statement.query([]).map_err(sqlite_error)?; + let mut expected_ordinal = 0u64; + let mut chunks = 0u64; + let mut payload_bytes = 0u64; + let mut imports = 0u64; + let mut import_payload_bytes = 0u64; + let mut terminal = None; + while let Some(row) = rows.next().map_err(sqlite_error)? { + checkpoint(control)?; + let ordinal = + u64::try_from(row.get::<_, i64>(0).map_err(sqlite_error)?).map_err(contract_number)?; + let cumulative_digest: String = row.get(1).map_err(sqlite_error)?; + let page_chunks = + u64::try_from(row.get::<_, i64>(2).map_err(sqlite_error)?).map_err(contract_number)?; + let page_payload = + u64::try_from(row.get::<_, i64>(3).map_err(sqlite_error)?).map_err(contract_number)?; + let page_imports = + u64::try_from(row.get::<_, i64>(4).map_err(sqlite_error)?).map_err(contract_number)?; + let page_import_payload = + u64::try_from(row.get::<_, i64>(5).map_err(sqlite_error)?).map_err(contract_number)?; + let import_digest: String = row.get(6).map_err(sqlite_error)?; + let cursor_bytes: Vec = row.get(7).map_err(sqlite_error)?; + let cursor = decode_cursor(&cursor_bytes)?; + chunks = chunks + .checked_add(page_chunks) + .ok_or_else(source_chain_overflow)?; + payload_bytes = payload_bytes + .checked_add(page_payload) + .ok_or_else(source_chain_overflow)?; + imports = imports + .checked_add(page_imports) + .ok_or_else(source_chain_overflow)?; + import_payload_bytes = import_payload_bytes + .checked_add(page_import_payload) + .ok_or_else(source_chain_overflow)?; + if ordinal != expected_ordinal + || cursor.next_page_ordinal() != expected_ordinal + 1 + || cursor.emitted_chunks() != chunks + || cursor.emitted_payload_bytes() != payload_bytes + || cursor.emitted_imports() != imports + || cursor.emitted_import_payload_bytes() != import_payload_bytes + || cursor.cumulative_digest().as_str() != cumulative_digest + || cursor.import_dictionary_digest().as_str() != import_digest + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact source-page cursor chain is inconsistent".to_owned(), + )); } + expected_ordinal = expected_ordinal + .checked_add(1) + .ok_or_else(source_chain_overflow)?; + terminal = Some(cursor); } + source + .verify_completion(terminal.as_ref()) + .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; Ok(()) } -fn insert_exact( - transaction: &Transaction<'_>, - document: i64, - row: &ProjectedChunkV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { - insert_exact_posting( - transaction, - &encode_exact_field(ExactFieldV1::Path)?, - Cow::Borrowed(row.logical_path.as_bytes()), - document, - )?; - let mut encoded_fields = BTreeMap::new(); - for term in &row.exact_terms { - let field = exact_field_for_kind(term.kind()); - let encoded = match encoded_fields.entry(field) { - Entry::Vacant(slot) => &*slot.insert(encode_exact_field(field)?), - Entry::Occupied(slot) => slot.into_mut(), - }; - insert_exact_posting( - transaction, - encoded, - canonical_projected_exact_term(term), - document, - )?; - } - Ok(()) -} - -fn insert_exact_posting( - transaction: &Transaction<'_>, - field: &str, - term: Cow<'_, [u8]>, - document: i64, -) -> Result<(), CodeLexicalArtifactErrorV1> { - transaction - .prepare_cached( - "INSERT OR IGNORE INTO exact_postings(field, term, document_id) VALUES (?1, ?2, ?3)", - ) - .map_err(sqlite_error)? - .execute(params![field, term.as_ref(), document]) - .map_err(sqlite_error)?; - Ok(()) -} - -fn document_integrity_digest( - transaction: &Transaction<'_>, - document: i64, - control: &dyn CodeIndexExecutionControlV1, -) -> Result { - checkpoint(control)?; - let mut hasher = Sha256::new(); - hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v1\0"); - hasher.update(document.to_le_bytes()); - let row_count = hash_document_table( - transaction, - &mut hasher, - "row", - "SELECT row FROM rows WHERE document_id = ?1", - document, - control, - )?; - if row_count != 1 { - return Err(CodeLexicalArtifactErrorV1::Corrupt(format!( - "lexical artifact document {document} is missing its derived row" - ))); - } - hash_document_table( - transaction, - &mut hasher, - "term_posting", - DOCUMENT_TERM_POSTINGS_QUERY, - document, - control, - )?; - hash_document_table( - transaction, - &mut hasher, - "exact_posting", - DOCUMENT_EXACT_POSTINGS_QUERY, - document, - control, - )?; - hash_document_table( - transaction, - &mut hasher, - "ngram_posting", - DOCUMENT_NGRAM_POSTINGS_QUERY, - document, - control, - )?; - integrity_digest(hasher) +fn source_chain_overflow() -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact source-page chain counter overflowed".to_owned(), + ) } -fn hash_document_table( - transaction: &Transaction<'_>, - hasher: &mut Sha256, - table: &str, - query: &str, - document: i64, - control: &dyn CodeIndexExecutionControlV1, -) -> Result { - checkpoint(control)?; - hasher.update( - u64::try_from(table.len()) - .map_err(contract_number)? - .to_le_bytes(), - ); - hasher.update(table.as_bytes()); - let mut statement = transaction.prepare(query).map_err(sqlite_error)?; - let column_count = statement.column_count(); - let mut rows = statement.query([document]).map_err(sqlite_error)?; - let mut count = 0u64; - while let Some(row) = rows.next().map_err(sqlite_error)? { - checkpoint(control)?; - hasher.update(b"row\0"); - for column in 0..column_count { - hash_value(hasher, row.get_ref(column).map_err(sqlite_error)?)?; - } - count = count.checked_add(1).ok_or_else(|| { - CodeLexicalArtifactErrorV1::Contract( - "lexical artifact document integrity row count overflowed".to_owned(), - ) - })?; +fn verify_sealed_receipt_header( + receipt: &VerifiedCodeLexicalArtifactV1, + expected_metadata_digest: &ManifestDigest, + source: &VerifiedSealedLexicalSourceReceiptV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + verify_source_receipt(receipt, source)?; + if receipt.metadata_digest() != expected_metadata_digest { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "finalized lexical artifact metadata digest changed".to_owned(), + )); } - hasher.update(b"end\0"); - hasher.update(count.to_le_bytes()); - Ok(count) -} - -fn import_integrity_digest( - canonical: &[u8], - evidence: &[u8], -) -> Result { - let mut hasher = Sha256::new(); - hasher.update(b"tracedecay.code-lexical-artifact-derived-import.v1\0"); - hash_bytes(&mut hasher, canonical)?; - hash_bytes(&mut hasher, evidence)?; - integrity_digest(hasher) + Ok(()) } -fn integrity_digest(hasher: Sha256) -> Result { - ManifestDigest::from_sha256_bytes(&hasher.finalize()) - .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string())) +fn verify_final_sections_against_source( + sections: &[CodeLexicalArtifactSectionDigestV1], + source: &VerifiedSealedLexicalSourceReceiptV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + let expected = [ + ("source_pages", source.page_count()), + ("document_integrity", source.total_chunks()), + ("import_integrity", source.total_imports()), + ("import_evidence", source.total_imports()), + ("rows", source.total_chunks()), + ]; + for (name, expected_rows) in expected { + let actual = sections + .iter() + .find(|section| section.name == name) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact finalization omitted a required section".to_owned(), + ) + })?; + if actual.row_count != expected_rows { + return Err(CodeLexicalArtifactErrorV1::Corrupt(format!( + "lexical artifact {name} rows disagree with the sealed source receipt" + ))); + } + } + Ok(()) } /// One staged `source_pages` receipt row: page and cumulative digests, chunk @@ -2478,10 +4098,71 @@ pub(super) fn compute_section_digests( connection: &Connection, control: &dyn CodeIndexExecutionControlV1, ) -> Result, CodeLexicalArtifactErrorV1> { - FinalizationSectionV1::ALL - .into_iter() - .map(|section| digest_query(connection, section, control)) - .collect() + let (source_pages, base_sections) = digest_source_pages_and_base_receipts(connection, control)?; + let mut sections = Vec::with_capacity(SECTION_NAMES.len()); + sections.push(source_pages); + sections.extend(base_sections); + for section in [ + FinalizationSectionV1::FieldStatistics, + FinalizationSectionV1::TermStatistics, + FinalizationSectionV1::Vocabulary, + ] { + sections.push(digest_query(connection, section, control)?); + } + Ok(sections) +} + +fn digest_source_pages_and_base_receipts( + connection: &Connection, + control: &dyn CodeIndexExecutionControlV1, +) -> Result< + ( + CodeLexicalArtifactSectionDigestV1, + Vec, + ), + CodeLexicalArtifactErrorV1, +> { + let section = FinalizationSectionV1::SourcePages; + let mut row_count = 0u64; + let mut accumulator = initial_section_accumulator(section.name())?.to_vec(); + let (mut base_row_counts, mut base_accumulators) = initial_base_section_receipt_fold()?; + let mut statement = connection + .prepare(section.full_query()) + .map_err(sqlite_error)?; + let column_count = statement.column_count(); + let mut rows = statement.query([]).map_err(sqlite_error)?; + while let Some(row) = rows.next().map_err(sqlite_error)? { + checkpoint(control)?; + let page_ordinal = + u64::try_from(row.get::<_, i64>(0).map_err(sqlite_error)?).map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt has a negative page".to_owned(), + ) + })?; + let receipt: Vec = row.get(9).map_err(sqlite_error)?; + absorb_page_base_sections_receipt( + page_ordinal, + &receipt, + &mut base_row_counts, + &mut base_accumulators, + )?; + absorb_section_row( + section.name(), + row_count, + &mut accumulator, + row, + column_count, + )?; + row_count = row_count.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact source-page receipt count overflowed".to_owned(), + ) + })?; + } + Ok(( + finish_section(section.name(), row_count, &accumulator)?, + finish_base_section_receipt_fold(&base_row_counts, &base_accumulators)?, + )) } fn digest_query( @@ -2598,6 +4279,7 @@ fn verify_source_receipt( Ok(()) } +#[cfg(feature = "hotpath")] fn record_finalization_step(step: &CodeLexicalArtifactFinalizationStepV1) { match step { CodeLexicalArtifactFinalizationStepV1::Pending { completed_rows, .. } => { @@ -2614,7 +4296,182 @@ fn record_finalization_step(step: &CodeLexicalArtifactFinalizationStepV1) { } } -#[hotpath::measure(label = "query.artifact.verify")] +#[cfg(not(feature = "hotpath"))] +fn record_finalization_step(step: &CodeLexicalArtifactFinalizationStepV1) { + let _ = step; +} + +#[cfg(feature = "hotpath")] +fn record_batch_outcome( + result: &Result, +) { + match result { + Ok(_) => { + hotpath::gauge!("query.artifact.batch.outcome.committed_total").inc(1u64); + } + Err(CodeLexicalArtifactErrorV1::Interrupted(_)) => { + hotpath::gauge!("query.artifact.batch.outcome.interrupted_total").inc(1u64); + } + Err(_) => { + hotpath::gauge!("query.artifact.batch.outcome.failed_total").inc(1u64); + } + } +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_outcome( + result: &Result, +) { + let _ = result; +} + +#[cfg(feature = "hotpath")] +fn record_prepared_batch_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let documents = pages.iter().map(|page| page.documents.len()).sum::(); + let source_bytes = pages + .iter() + .map(PreparedCodeLexicalArtifactPageV1::source_retained_bytes) + .sum::(); + let prepared_bytes = pages + .iter() + .map(PreparedCodeLexicalArtifactPageV1::retained_owned_bytes) + .sum::(); + let effective_workers = tracedecay_code_index::parallelism::indexing_workers().min(pages.len()); + let mut scratch = pages + .iter() + .map(PreparedCodeLexicalArtifactPageV1::preparation_scratch_bytes) + .collect::>(); + scratch.sort_unstable_by(|left, right| right.cmp(left)); + let active_scratch = scratch.into_iter().take(effective_workers).sum::(); + hotpath::gauge!("query.artifact.batch.prepared_pages_total").inc(pages.len() as u64); + hotpath::gauge!("query.artifact.batch.prepared_documents_total").inc(documents as u64); + hotpath::gauge!("query.artifact.batch.source_bytes_total").inc(source_bytes as u64); + hotpath::gauge!("query.artifact.batch.prepared_bytes_total").inc(prepared_bytes as u64); + hotpath::gauge!("query.artifact.batch.active_scratch_bytes_total").inc(active_scratch as u64); + hotpath::gauge!("query.artifact.batch.effective_workers").set(effective_workers as u64); +} + +#[cfg(not(feature = "hotpath"))] +fn record_prepared_batch_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let _ = pages; +} + +#[cfg(feature = "hotpath")] +fn record_batch_import_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let imports = pages.iter().map(|page| page.imports.len()).sum::(); + hotpath::gauge!("query.artifact.batch.import_rows_total").inc(imports as u64); +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_import_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let _ = pages; +} + +#[cfg(feature = "hotpath")] +fn record_batch_posting_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let relational_postings = pages + .iter() + .flat_map(|page| &page.documents) + .map(|document| document.term_postings.len() + document.exact_postings.len()) + .sum::(); + let ngram_shards = pages + .iter() + .map(|page| page.ngram_shards.len()) + .sum::(); + let ngram_documents = pages + .iter() + .flat_map(|page| &page.ngram_shards) + .map(|shard| shard.cardinality) + .sum::(); + let ngram_bytes = pages + .iter() + .flat_map(|page| &page.ngram_shards) + .map(|shard| shard.documents.len()) + .sum::(); + hotpath::gauge!("query.artifact.batch.posting_rows_total").inc(relational_postings as u64); + hotpath::gauge!("query.artifact.batch.ngram_shard_rows_total").inc(ngram_shards as u64); + hotpath::gauge!("query.artifact.batch.ngram_documents_total").inc(ngram_documents); + hotpath::gauge!("query.artifact.batch.ngram_bytes_total").inc(ngram_bytes as u64); +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_posting_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let _ = pages; +} + +#[cfg(feature = "hotpath")] +fn record_batch_row_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let rows = pages.iter().map(|page| page.documents.len()).sum::(); + hotpath::gauge!("query.artifact.batch.document_rows_total").inc(rows as u64); +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_row_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let _ = pages; +} + +#[cfg(feature = "hotpath")] +fn record_batch_receipt_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + hotpath::gauge!("query.artifact.batch.receipt_rows_total").inc(pages.len() as u64); +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_receipt_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let _ = pages; +} + +#[cfg(feature = "hotpath")] +fn record_batch_prefix_limit(limit: CodeLexicalArtifactBatchLimitV1) { + match limit { + CodeLexicalArtifactBatchLimitV1::Memory => { + hotpath::gauge!("query.artifact.batch.prefix_limited.memory_total").inc(1u64); + } + CodeLexicalArtifactBatchLimitV1::PreparedRows => { + hotpath::gauge!("query.artifact.batch.prefix_limited.prepared_rows_total").inc(1u64); + } + CodeLexicalArtifactBatchLimitV1::EstimatedWriteBytes => { + hotpath::gauge!("query.artifact.batch.prefix_limited.estimated_write_bytes_total") + .inc(1u64); + } + } +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_prefix_limit(limit: CodeLexicalArtifactBatchLimitV1) { + let _ = limit; +} + +#[cfg(feature = "hotpath")] +fn record_artifact_progress(progress: &CodeLexicalArtifactBuildProgressV1) { + hotpath::gauge!("query.artifact.pages").set(progress.next_page_ordinal); + hotpath::gauge!("query.artifact.rows").set(progress.completed_chunks); + hotpath::gauge!("query.artifact.bytes").set(progress.completed_payload_bytes); +} + +#[cfg(not(feature = "hotpath"))] +fn record_artifact_progress(progress: &CodeLexicalArtifactBuildProgressV1) { + let _ = progress; +} + +fn commit_finalization_transaction( + transaction: Transaction<'_>, + metrics: &mut FinalizationTransactionMetricsV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.finalization.commit_attempts_total").inc(1u64); + let result = hotpath::measure_block!( + "query.artifact.finalization.commit", + transaction.commit().map_err(sqlite_error) + ); + if result.is_ok() { + metrics.mark_committed(); + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.finalization.commit_succeeded_total").inc(1u64); + } + result +} + +#[hotpath::measure(label = "query.artifact.finalization.sealed_verify")] fn verify_finalized_artifact( connection: &Connection, path: &Path, @@ -2699,9 +4556,15 @@ fn contract_number(error: impl std::fmt::Display) -> CodeLexicalArtifactErrorV1 #[cfg(test)] mod tests { + use super::super::format::encode_ngram_bitmap; use super::*; + use roaring::RoaringBitmap; use rusqlite::StatementStatus; - use std::sync::atomic::{AtomicUsize, Ordering}; + use rusqlite::hooks::{AuthAction, Authorization}; + use tracedecay_domain::{ + CodeGenerationId, ComponentRevision, FreshnessCompatibilityV1, ScoreDomainId, + SourceFreshness, SourceInstanceKey, SourceNamespace, UtcMicros, + }; struct ActiveControl; @@ -2715,19 +4578,305 @@ mod tests { } } - struct CancelAtCheckpoint { - checkpoint: usize, - observations: AtomicUsize, + fn test_metadata() -> CodeLexicalProjectionMetadataV1 { + CodeLexicalProjectionMetadataV1 { + generation: CodeGenerationId::new("generation.artifact-builder.v1") + .expect("generation"), + repository_id: None, + logical_paths: Default::default(), + freshness: SourceFreshness { + source_namespace: SourceNamespace::new("namespace.artifact-builder") + .expect("namespace"), + source_instance: SourceInstanceKey::new("instance.artifact-builder") + .expect("instance"), + source_watermark: None, + projection_watermark: None, + observed_at: UtcMicros(0), + source_generation: None, + generation_lag: None, + compatibility: FreshnessCompatibilityV1::Unknown, + policy_revision: ComponentRevision::new("policy.artifact-builder.v1") + .expect("policy"), + }, + exact_retriever_revision: ComponentRevision::new("retriever.exact.artifact.v1") + .expect("exact retriever"), + lexical_retriever_revision: ComponentRevision::new("retriever.lexical.artifact.v1") + .expect("lexical retriever"), + exact_score_domain: ScoreDomainId::new("score.exact.artifact.v1") + .expect("score domain"), + } + } + + fn create_mutable_test_schema(connection: &Connection) -> BuilderMutationGuardV1 { + let gate = + register_builder_mutation_gate(connection).expect("register builder mutation gate"); + create_schema(connection).expect("create artifact schema"); + BuilderMutationGuardV1::enter(&gate).expect("enter test builder mutation authority") } - impl CodeIndexExecutionControlV1 for CancelAtCheckpoint { - fn is_cancelled(&self) -> bool { - self.observations.fetch_add(1, Ordering::SeqCst) + 1 >= self.checkpoint + #[test] + fn canonical_batch_limits_select_a_multi_page_prefix_without_stalling() { + let page_bounds = [ + (700_000usize, 80 * 1024 * 1024usize), + (700_000, 80 * 1024 * 1024), + (700_000, 80 * 1024 * 1024), + ]; + let mut ledger = CanonicalBatchLimitLedgerV1::default(); + let selected = page_bounds + .into_iter() + .take_while(|(rows, bytes)| { + ledger + .try_admit(*rows, *bytes) + .expect("extend canonical limit ledger") + .is_none() + }) + .count(); + assert_eq!( + selected, 2, + "two pages fit both canonical caps and the third must remain for the next wake" + ); + } + + #[test] + fn canonical_write_limit_refuses_ngram_receipt_past_the_exact_boundary() { + let ngram_receipt_bytes = "sha256:".len() + 64; + let mut ledger = CanonicalBatchLimitLedgerV1::default(); + assert!( + ledger + .try_admit( + 0, + CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1 + - ngram_receipt_bytes, + ) + .expect("admit bytes below the ngram receipt boundary") + .is_none() + ); + let exceeded = ledger + .try_admit(0, ngram_receipt_bytes + 1) + .expect("evaluate ngram receipt boundary") + .expect("one byte past the write boundary must be refused"); + assert_eq!( + exceeded.limit, + CodeLexicalArtifactBatchLimitV1::EstimatedWriteBytes + ); + assert_eq!( + exceeded.required, + CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1 + 1 + ); + } + + #[test] + fn receipt_verification_does_not_read_append_only_base_tables() { + let connection = Connection::open_in_memory().expect("open artifact database"); + let _mutation_authority = create_mutable_test_schema(&connection); + connection + .authorizer(Some( + |context: rusqlite::hooks::AuthContext<'_>| match context.action { + AuthAction::Read { table_name, .. } + if BASE_SECTION_NAMES.contains(&table_name) => + { + Authorization::Deny + } + _ => Authorization::Allow, + }, + )) + .expect("deny exhaustive base-table verification reads"); + + let sections = compute_section_digests(&connection, &ActiveControl) + .expect("verify only source-page receipts and derived sections"); + assert_eq!( + sections + .iter() + .map(|section| section.name.as_str()) + .collect::>(), + SECTION_NAMES + ); + } + + #[test] + fn finalization_monitor_spawn_failure_is_typed_and_leaves_sqlite_reusable() { + let mut connection = Connection::open_in_memory().expect("open artifact database"); + let _mutation_authority = create_mutable_test_schema(&connection); + let transaction = connection.transaction().expect("start transaction"); + fail_next_finalization_monitor_spawn(); + let operation_ran = AtomicBool::new(false); + let error = with_cancellable_sqlite_statement(&transaction, &ActiveControl, || { + operation_ran.store(true, Ordering::SeqCst); + Ok(()) + }) + .expect_err("injected monitor spawn failure must be typed"); + assert!(matches!(error, CodeLexicalArtifactErrorV1::Io(_))); + assert!( + !operation_ran.load(Ordering::SeqCst), + "SQLite work must not start without its cancellation monitor" + ); + with_cancellable_sqlite_statement(&transaction, &ActiveControl, || { + transaction + .query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) + .map_err(sqlite_error) + }) + .expect("progress handler is cleared after spawn failure"); + } + + #[test] + fn ngram_staging_key_preserves_source_page_order_without_a_serving_index() { + let connection = Connection::open_in_memory().expect("open artifact database"); + let _mutation_authority = create_mutable_test_schema(&connection); + for (page_ordinal, ngram, document) in + [(0i64, 90i64, 0u32), (0, 100, 0), (1, 10, 1), (1, 20, 1)] + { + let bitmap = RoaringBitmap::from_iter([document]); + let encoded = encode_ngram_bitmap(&bitmap).expect("encode ngram bitmap"); + connection + .execute( + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (?1, 1, ?2, ?3, 1)", + params![page_ordinal, ngram, encoded], + ) + .expect("seed page-ordered ngram posting"); } - fn is_deadline_exceeded(&self) -> bool { - false + let serving_indexes: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pragma_index_list('ngram_postings') WHERE name = 'ngram_postings_by_ngram'", + [], + |row| row.get(0), + ) + .expect("inspect staging indexes"); + assert_eq!( + serving_indexes, 0, + "serving-key maintenance must remain absent during catch-up" + ); + + let mut statement = connection + .prepare( + "SELECT page_ordinal, kind, ngram FROM ngram_postings ORDER BY page_ordinal, kind, ngram", + ) + .expect("prepare staging-order scan"); + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .expect("scan staging order") + .collect::, _>>() + .expect("collect staging order"); + assert_eq!(rows, [(0, 1, 90), (0, 1, 100), (1, 1, 10), (1, 1, 20)]); + assert_eq!( + statement.get_status(StatementStatus::Sort), + 0, + "source-page catch-up must be the maintained table order" + ); + } + + #[test] + fn deferred_ngram_serving_index_is_unique_and_query_selective() { + let mut connection = Connection::open_in_memory().expect("open artifact database"); + let _mutation_authority = create_mutable_test_schema(&connection); + for (page_ordinal, ngram, documents) in [ + (0i64, 10i64, vec![1u32, 2]), + (0, 20, vec![1]), + (1, 10, vec![3]), + ] { + let bitmap = RoaringBitmap::from_iter(documents); + let encoded = encode_ngram_bitmap(&bitmap).expect("encode ngram bitmap"); + connection + .execute( + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (?1, 1, ?2, ?3, ?4)", + params![page_ordinal, ngram, encoded, bitmap.len() as i64], + ) + .expect("seed ngram posting"); } + + let transaction = connection + .transaction() + .expect("start serving-index transaction"); + build_serving_index_step(&transaction, 6).expect("build ngram serving index"); + transaction.commit().expect("commit ngram serving index"); + + let unique: i64 = connection + .query_row( + "SELECT [unique] FROM pragma_index_list('ngram_postings') WHERE name = 'ngram_postings_by_ngram'", + [], + |row| row.get(0), + ) + .expect("inspect ngram serving index"); + assert_eq!( + unique, 1, + "the serving index must preserve posting identity" + ); + let columns = connection + .prepare( + "SELECT name FROM pragma_index_xinfo('ngram_postings_by_ngram') WHERE key = 1 ORDER BY seqno", + ) + .expect("prepare serving-index columns") + .query_map([], |row| row.get::<_, String>(0)) + .expect("query serving-index columns") + .collect::, _>>() + .expect("collect serving-index columns"); + assert_eq!(columns, ["kind", "ngram", "page_ordinal"]); + + let query = "SELECT documents, cardinality FROM ngram_postings \ + WHERE kind = ?1 AND ngram = ?2 ORDER BY page_ordinal"; + let plan = connection + .prepare(&format!("EXPLAIN QUERY PLAN {query}")) + .expect("prepare ngram serving plan") + .query_map(params![1i64, 10i64], |row| row.get::<_, String>(3)) + .expect("query ngram serving plan") + .collect::, _>>() + .expect("collect ngram serving plan"); + assert!( + plan.iter() + .any(|detail| detail.contains("USING INDEX ngram_postings_by_ngram")), + "phrase candidates must use the deferred serving index, got {plan:?}" + ); + let shard_cardinalities = connection + .prepare(query) + .expect("prepare ngram serving query") + .query_map(params![1i64, 10i64], |row| row.get::<_, i64>(1)) + .expect("query ngram candidates") + .collect::, _>>() + .expect("collect ngram candidates"); + assert_eq!(shard_cardinalities, [2, 1]); + } + + #[test] + fn resume_refuses_the_superseded_ngram_staging_layout() { + let directory = tempfile::tempdir().expect("artifact tempdir"); + let path = directory.path().join("superseded-ngram-layout.sqlite"); + let metadata = test_metadata(); + drop( + CodeLexicalArtifactBuilderV1::create(&path, metadata.clone()) + .expect("create current staging artifact"), + ); + let connection = Connection::open(&path).expect("open staging artifact for fixture setup"); + connection + .execute_batch( + "ALTER TABLE ngram_postings RENAME TO current_ngram_postings; + CREATE TABLE ngram_postings ( + kind INTEGER NOT NULL, + ngram INTEGER NOT NULL, + document_id INTEGER NOT NULL, + PRIMARY KEY(kind, ngram, document_id) + ) WITHOUT ROWID; + DROP TABLE current_ngram_postings;", + ) + .expect("install superseded branch-local layout"); + drop(connection); + + let error = + match CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &path, + metadata, + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &ActiveControl, + ) { + Ok(_) => panic!("resume must not mix superseded and current ngram layouts"), + Err(error) => error, + }; + assert!(matches!(error, CodeLexicalArtifactErrorV1::Incompatible(_))); } #[test] @@ -2773,9 +4922,9 @@ mod tests { } #[test] - fn one_document_integrity_row_does_not_visit_unrelated_generation_rows() { + fn one_document_integrity_row_does_not_visit_unrelated_relational_rows() { let mut connection = Connection::open_in_memory().expect("open artifact database"); - create_schema(&connection).expect("create artifact schema"); + let _mutation_authority = create_mutable_test_schema(&connection); let transaction = connection.transaction().expect("start seed transaction"); for document_id in 0..2_048i64 { transaction @@ -2790,19 +4939,20 @@ mod tests { params![document_id.to_le_bytes().as_slice(), document_id], ) .expect("seed exact posting"); - transaction - .execute( - "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (1, ?1, ?2)", - params![document_id, document_id], - ) - .expect("seed ngram posting"); } transaction.commit().expect("commit seed transaction"); + let transaction = connection + .transaction() + .expect("start index-build transaction"); + for ordinal in [2, 5] { + build_serving_index_step(&transaction, ordinal) + .expect("build document integrity index"); + } + transaction.commit().expect("commit integrity index"); for query in [ - DOCUMENT_TERM_POSTINGS_QUERY, - DOCUMENT_EXACT_POSTINGS_QUERY, - DOCUMENT_NGRAM_POSTINGS_QUERY, + "SELECT field, term, frequency FROM term_postings INDEXED BY term_postings_by_document WHERE document_id = ?1 ORDER BY field, term", + "SELECT field, term FROM exact_postings WHERE document_id = ?1 ORDER BY field, term", ] { let mut statement = connection.prepare(query).expect("prepare integrity query"); { @@ -2823,88 +4973,19 @@ mod tests { } } - #[test] - fn document_integrity_hashing_cancels_without_advancing_finalization() { - let mut connection = Connection::open_in_memory().expect("open artifact database"); - create_schema(&connection).expect("create artifact schema"); - let transaction = connection - .transaction() - .expect("start artifact transaction"); - transaction - .execute( - "INSERT INTO rows(document_id, chunk_id, row) VALUES (0, 'chunk', X'01')", - [], - ) - .expect("seed row"); - for ordinal in 0..64i64 { - transaction - .execute( - "INSERT INTO term_postings(field, term, document_id, frequency) VALUES ('body', ?1, 0, 1)", - [format!("term-{ordinal:02}")], - ) - .expect("seed term posting"); - } - let digest = document_integrity_digest(&transaction, 0, &ActiveControl) - .expect("derive append receipt"); - transaction - .execute( - "INSERT INTO document_integrity(document_id, digest) VALUES (0, ?1)", - [digest.as_str()], - ) - .expect("seed document receipt"); - - let mut state = PersistedFinalizationStateV1 { - phase: PersistedFinalizationPhaseV1::Build, - section_ordinal: 1, - section_row_count: 0, - section_last_key: None, - section_accumulator: initial_section_accumulator("document_integrity") - .expect("initial accumulator") - .to_vec(), - completed_sections: Vec::new(), - completed_rows: 0, - content_epoch: 0, - source_state_digest: ManifestDigest::new(format!("sha256:{}", "0".repeat(64))) - .expect("test source-state digest"), - }; - let control = CancelAtCheckpoint { - checkpoint: 8, - observations: AtomicUsize::new(0), - }; - let error = advance_native_section_rows( - &transaction, - FinalizationSectionV1::DocumentIntegrity, - FinalizationSectionV1::DocumentIntegrity.seek_query(false), - params![1i64], - &mut state, - &control, - ) - .expect_err("cancel nested document hashing"); - - assert!(matches!( - error, - CodeLexicalArtifactErrorV1::Interrupted( - tracedecay_code_index::production::CodeIndexInterruptionV1::Cancelled - ) - )); - assert_eq!(state.section_row_count, 0); - assert_eq!(state.completed_rows, 0); - assert_eq!(state.section_last_key, None); - } - #[test] fn bounded_finalization_resume_seeks_each_native_section_index() { let connection = Connection::open_in_memory().expect("open artifact database"); - create_schema(&connection).expect("create artifact schema"); + let _mutation_authority = create_mutable_test_schema(&connection); connection .execute( - "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor) VALUES (0, 'page', 'cumulative', 1, 1, 1, 1, 'imports', X'00')", + "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, base_sections_receipt, next_cursor) VALUES (0, 'page', 'cumulative', 1, 1, 1, 1, 'imports', 'ngrams', X'00', X'00')", [], ) .expect("seed source page"); connection .execute( - "INSERT INTO document_integrity(document_id, digest) VALUES (0, 'document')", + "INSERT INTO document_integrity(document_id, chunk_id, digest) VALUES (0, 'chunk', 'document')", [], ) .expect("seed document integrity"); @@ -2938,10 +5019,12 @@ mod tests { [], ) .expect("seed exact posting"); + let encoded = + encode_ngram_bitmap(&RoaringBitmap::from_iter([0])).expect("encode ngram bitmap"); connection .execute( - "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (1, 1, 0)", - [], + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (0, 1, 1, ?1, 1)", + [encoded], ) .expect("seed ngram posting"); connection @@ -2959,6 +5042,12 @@ mod tests { connection .execute("INSERT INTO vocabulary(term) VALUES ('term')", []) .expect("seed vocabulary"); + let transaction = connection + .unchecked_transaction() + .expect("start ngram serving-index transaction"); + build_serving_index_step(&transaction, 6) + .expect("build ngram serving index before digest verification"); + transaction.commit().expect("commit ngram serving index"); for section in FinalizationSectionV1::ALL { let plan = explain_native_seek_plan(&connection, section) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs index 4f027760d1..8abe1cba0b 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; +use roaring::RoaringBitmap; use rusqlite::{Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -18,13 +19,25 @@ use super::CodeLexicalArtifactErrorV1; /// are branch-only staging files and must fail as incompatible rather than be /// partially interpreted against this schema. // Revision 3 replaces the branch-local computed finalization cursor with -// native table keys. Revision 4 adds document-leading indexes so verifying -// one document's receipt never scans an unrelated generation. Revision 5 -// adds the term-leading statistics index and term-selective document index -// required by batched lexical reads. -pub(super) const CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1: u32 = 5; -const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"tracedecay.code-lexical-artifact.v5\0"; -const REQUIRED_ARTIFACT_INDEXES_V5: [(&str, &str, &[&str]); 3] = [ +// native table keys. Revision 4 adds document-leading indexes. Revision 5 +// adds term-selective read indexes. Revision 6 makes the append authority +// immutable before one authenticated digest pass, defers every serving index +// until resumable finalization, and keeps ngram catch-up document-leading. +// Revision 7 replaces one row per document n-gram with deterministic +// source-page Roaring bitmap shards. Revision 8 adds source-page receipts for +// every append-only base section so sealing and reopening need not rescan the +// relational base after the private builder connection has admitted it. +// Revision 9 persists parser-attested symbol display identity with each row so +// graph-independent result hydration never needs the full sealed generation. +pub(super) const CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1: u32 = 9; +const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"tracedecay.code-lexical-artifact.v9\0"; +const REQUIRED_ARTIFACT_INDEXES_V8: [(&str, &str, &[&str]); 7] = [ + ("rows", "rows_by_chunk", &["chunk_id"]), + ( + "term_postings", + "term_postings_by_term", + &["term", "field", "document_id"], + ), ( "term_postings", "term_postings_by_document", @@ -36,6 +49,16 @@ const REQUIRED_ARTIFACT_INDEXES_V5: [(&str, &str, &[&str]); 3] = [ &["document_id", "term", "field", "frequency"], ), ("term_stats", "term_stats_by_term", &["term", "field"]), + ( + "exact_postings", + "exact_postings_by_document", + &["document_id", "field", "term"], + ), + ( + "ngram_postings", + "ngram_postings_by_ngram", + &["kind", "ngram", "page_ordinal"], + ), ]; pub(super) const RECEIPT_RESERVATION_BYTES: usize = 16 * 1024; pub(super) const SECTION_NAMES: [&str; 11] = [ @@ -51,10 +74,254 @@ pub(super) const SECTION_NAMES: [&str; 11] = [ "term_stats", "vocabulary", ]; +pub(super) const BASE_SECTION_NAMES: [&str; 7] = [ + "document_integrity", + "import_integrity", + "import_evidence", + "rows", + "term_postings", + "exact_postings", + "ngram_postings", +]; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(super) struct CodeLexicalArtifactPageBaseSectionsReceiptV1 { + page_ordinal: u64, + sections: Vec, +} + +pub(super) struct PageBaseSectionReceiptBuilderV1 { + page_ordinal: u64, + name: &'static str, + row_count: u64, + hasher: Sha256, +} + +impl PageBaseSectionReceiptBuilderV1 { + pub(super) fn new( + page_ordinal: u64, + name: &'static str, + ) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-page-section.v1\0"); + hasher.update(page_ordinal.to_le_bytes()); + hasher.update( + u64::try_from(name.len()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))? + .to_le_bytes(), + ); + hasher.update(name.as_bytes()); + Ok(Self { + page_ordinal, + name, + row_count: 0, + hasher, + }) + } + + pub(super) fn begin_row(&mut self) -> Result<(), CodeLexicalArtifactErrorV1> { + self.hasher.update(b"row\0"); + self.hasher.update(self.row_count.to_le_bytes()); + self.row_count = self.row_count.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page-section row count overflowed".to_owned(), + ) + })?; + Ok(()) + } + + pub(super) fn integer(&mut self, value: i64) { + self.hasher.update([1]); + self.hasher.update(value.to_le_bytes()); + } + + pub(super) fn text(&mut self, value: &str) -> Result<(), CodeLexicalArtifactErrorV1> { + self.hasher.update([3]); + hash_receipt_bytes(&mut self.hasher, value.as_bytes()) + } + + pub(super) fn blob(&mut self, value: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { + self.hasher.update([4]); + hash_receipt_bytes(&mut self.hasher, value) + } + + pub(super) fn finish( + mut self, + ) -> Result { + self.hasher.update(b"end\0"); + self.hasher.update(self.page_ordinal.to_le_bytes()); + self.hasher.update(self.row_count.to_le_bytes()); + let digest = ManifestDigest::from_sha256_bytes(&self.hasher.finalize()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + Ok(CodeLexicalArtifactSectionDigestV1 { + name: self.name.to_owned(), + row_count: self.row_count, + digest, + }) + } +} + +fn hash_receipt_bytes(hasher: &mut Sha256, value: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { + hasher.update( + u64::try_from(value.len()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))? + .to_le_bytes(), + ); + hasher.update(value); + Ok(()) +} + +pub(super) fn encode_page_base_sections_receipt( + page_ordinal: u64, + sections: Vec, +) -> Result, CodeLexicalArtifactErrorV1> { + validate_page_base_sections(page_ordinal, §ions)?; + serde_json::to_vec(&CodeLexicalArtifactPageBaseSectionsReceiptV1 { + page_ordinal, + sections, + }) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string())) +} + +pub(super) fn decode_page_base_sections_receipt( + expected_page_ordinal: u64, + bytes: &[u8], +) -> Result { + let receipt: CodeLexicalArtifactPageBaseSectionsReceiptV1 = serde_json::from_slice(bytes) + .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; + validate_page_base_sections(expected_page_ordinal, &receipt.sections)?; + if receipt.page_ordinal != expected_page_ordinal + || serde_json::to_vec(&receipt) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))? + != bytes + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact page base-section receipt is not canonical".to_owned(), + )); + } + Ok(receipt) +} + +impl CodeLexicalArtifactPageBaseSectionsReceiptV1 { + pub(super) fn sections(&self) -> &[CodeLexicalArtifactSectionDigestV1] { + &self.sections + } +} + +pub(super) fn initial_base_section_receipt_fold() +-> Result<(Vec, Vec>), CodeLexicalArtifactErrorV1> { + let row_counts = vec![0; BASE_SECTION_NAMES.len()]; + let accumulators = BASE_SECTION_NAMES + .into_iter() + .map(|name| { + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-base-receipt-fold.v1\0initial"); + hash_receipt_bytes(&mut hasher, name.as_bytes())?; + Ok(hasher.finalize().to_vec()) + }) + .collect::, CodeLexicalArtifactErrorV1>>()?; + Ok((row_counts, accumulators)) +} + +pub(super) fn absorb_page_base_sections_receipt( + page_ordinal: u64, + bytes: &[u8], + row_counts: &mut [u64], + accumulators: &mut [Vec], +) -> Result<(), CodeLexicalArtifactErrorV1> { + if row_counts.len() != BASE_SECTION_NAMES.len() + || accumulators.len() != BASE_SECTION_NAMES.len() + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt fold has the wrong width".to_owned(), + )); + } + let receipt = decode_page_base_sections_receipt(page_ordinal, bytes)?; + for (ordinal, section) in receipt.sections().iter().enumerate() { + let previous: [u8; 32] = accumulators[ordinal].as_slice().try_into().map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt accumulator has the wrong length".to_owned(), + ) + })?; + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-base-receipt-fold.v1\0page"); + hash_receipt_bytes(&mut hasher, section.name.as_bytes())?; + hasher.update(page_ordinal.to_le_bytes()); + hasher.update(section.row_count.to_le_bytes()); + hash_receipt_bytes(&mut hasher, section.digest.as_str().as_bytes())?; + hasher.update(previous); + accumulators[ordinal] = hasher.finalize().to_vec(); + row_counts[ordinal] = row_counts[ordinal] + .checked_add(section.row_count) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact base-section receipt row count overflowed".to_owned(), + ) + })?; + } + Ok(()) +} + +pub(super) fn finish_base_section_receipt_fold( + row_counts: &[u64], + accumulators: &[Vec], +) -> Result, CodeLexicalArtifactErrorV1> { + if row_counts.len() != BASE_SECTION_NAMES.len() + || accumulators.len() != BASE_SECTION_NAMES.len() + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt fold has the wrong width".to_owned(), + )); + } + BASE_SECTION_NAMES + .into_iter() + .enumerate() + .map(|(ordinal, name)| { + let accumulator: [u8; 32] = + accumulators[ordinal].as_slice().try_into().map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt accumulator has the wrong length" + .to_owned(), + ) + })?; + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-base-receipt-fold.v1\0final"); + hash_receipt_bytes(&mut hasher, name.as_bytes())?; + hasher.update(row_counts[ordinal].to_le_bytes()); + hasher.update(accumulator); + let digest = ManifestDigest::from_sha256_bytes(&hasher.finalize()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + Ok(CodeLexicalArtifactSectionDigestV1 { + name: name.to_owned(), + row_count: row_counts[ordinal], + digest, + }) + }) + .collect() +} + +fn validate_page_base_sections( + page_ordinal: u64, + sections: &[CodeLexicalArtifactSectionDigestV1], +) -> Result<(), CodeLexicalArtifactErrorV1> { + if sections.len() != BASE_SECTION_NAMES.len() + || sections + .iter() + .zip(BASE_SECTION_NAMES) + .any(|(section, expected)| section.name != expected) + { + return Err(CodeLexicalArtifactErrorV1::Corrupt(format!( + "lexical artifact page {page_ordinal} base-section receipt is malformed" + ))); + } + Ok(()) +} pub(super) fn verify_required_artifact_indexes( connection: &Connection, ) -> Result<(), CodeLexicalArtifactErrorV1> { + verify_artifact_table_layout(connection)?; let mut statement = connection .prepare("SELECT name, desc, coll FROM pragma_index_xinfo(?1) WHERE key = 1 ORDER BY seqno") .map_err(|error| { @@ -62,7 +329,7 @@ pub(super) fn verify_required_artifact_indexes( "artifact index schema is unreadable: {error}" )) })?; - for (table, index, expected_columns) in REQUIRED_ARTIFACT_INDEXES_V5 { + for (table, index, expected_columns) in REQUIRED_ARTIFACT_INDEXES_V8 { let partial: Option = connection .query_row( "SELECT partial FROM pragma_index_list(?1) WHERE name = ?2", @@ -110,6 +377,286 @@ pub(super) fn verify_required_artifact_indexes( Ok(()) } +pub(super) fn verify_artifact_table_layout( + connection: &Connection, +) -> Result<(), CodeLexicalArtifactErrorV1> { + let source_columns = connection + .prepare( + "SELECT name, type, [notnull], pk FROM pragma_table_xinfo('source_pages') WHERE hidden = 0 ORDER BY cid", + ) + .and_then(|mut statement| { + statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + })? + .collect::, _>>() + }) + .map_err(|error| { + CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact source-page columns are unreadable: {error}" + )) + })?; + let expected_source_columns = [ + ("page_ordinal", "INTEGER", 0, 1), + ("page_digest", "TEXT", 1, 0), + ("cumulative_digest", "TEXT", 1, 0), + ("chunk_count", "INTEGER", 1, 0), + ("payload_bytes", "INTEGER", 1, 0), + ("import_count", "INTEGER", 1, 0), + ("import_payload_bytes", "INTEGER", 1, 0), + ("import_dictionary_digest", "TEXT", 1, 0), + ("ngram_digest", "TEXT", 1, 0), + ("base_sections_receipt", "BLOB", 1, 0), + ("next_cursor", "BLOB", 1, 0), + ]; + if !source_columns + .iter() + .map(|(name, column_type, not_null, primary_key)| { + (name.as_str(), column_type.as_str(), *not_null, *primary_key) + }) + .eq(expected_source_columns) + { + return Err(CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact source-page table has columns {source_columns:?}; revision {CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1} requires append receipts" + ))); + } + let without_rowid: Option = connection + .query_row( + "SELECT wr FROM pragma_table_list WHERE schema = 'main' AND name = 'ngram_postings' AND type = 'table'", + [], + |row| row.get(0), + ) + .optional() + .map_err(|error| { + CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact ngram table schema is unreadable: {error}" + )) + })?; + let mut statement = connection + .prepare( + "SELECT name, type, [notnull], pk FROM pragma_table_xinfo('ngram_postings') WHERE hidden = 0 ORDER BY cid", + ) + .map_err(|error| { + CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact ngram columns are unreadable: {error}" + )) + })?; + let columns = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + }) + .map_err(|error| { + CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact ngram columns are unreadable: {error}" + )) + })? + .collect::, _>>() + .map_err(|error| { + CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact ngram columns are unreadable: {error}" + )) + })?; + let expected = [ + ("page_ordinal", "INTEGER", 1, 1), + ("kind", "INTEGER", 1, 2), + ("ngram", "INTEGER", 1, 3), + ("documents", "BLOB", 1, 0), + ("cardinality", "INTEGER", 1, 0), + ]; + if without_rowid != Some(1) + || !columns + .iter() + .map(|(name, column_type, not_null, primary_key)| { + (name.as_str(), column_type.as_str(), *not_null, *primary_key) + }) + .eq(expected) + { + return Err(CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact ngram table has columns {columns:?} and without-rowid state {without_rowid:?}; revision {CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1} requires source-page bitmap shards" + ))); + } + Ok(()) +} + +pub(super) fn ngram_page_digest<'a>( + page_ordinal: u64, + rows: impl IntoIterator, +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-ngram-page.v1\0"); + hasher.update(page_ordinal.to_le_bytes()); + let mut row_count = 0u64; + for (kind, ngram, documents, cardinality) in rows { + hasher.update(b"row\0"); + hasher.update(kind.to_le_bytes()); + hasher.update(ngram.to_le_bytes()); + hasher.update( + u64::try_from(documents.len()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))? + .to_le_bytes(), + ); + hasher.update(documents); + hasher.update(cardinality.to_le_bytes()); + row_count = row_count.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram shard count overflowed".to_owned(), + ) + })?; + } + hasher.update(b"end\0"); + hasher.update(row_count.to_le_bytes()); + ManifestDigest::from_sha256_bytes(&hasher.finalize()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string())) +} + +pub(super) fn encode_ngram_bitmap( + bitmap: &RoaringBitmap, +) -> Result, CodeLexicalArtifactErrorV1> { + let cardinality = bitmap.len(); + let mut range_count = 0u64; + let mut previous: Option = None; + for document in bitmap.iter() { + if previous.is_none_or(|previous| document != previous.saturating_add(1)) { + range_count = range_count.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram range count overflowed".to_owned(), + ) + })?; + } + previous = Some(document); + } + let list_bytes = cardinality.checked_mul(4).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram document-list size overflowed".to_owned(), + ) + })?; + let range_bytes = range_count.checked_mul(8).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram range size overflowed".to_owned(), + ) + })?; + let ranges = range_bytes < list_bytes; + let payload_bytes = if ranges { range_bytes } else { list_bytes }; + let capacity = usize::try_from(payload_bytes) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))? + .checked_add(16) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram bitmap size overflowed".to_owned(), + ) + })?; + let mut encoded = Vec::with_capacity(capacity); + encoded.extend_from_slice(b"TDN1"); + encoded.push(u8::from(ranges)); + encoded.extend_from_slice(&[0u8; 3]); + encoded.extend_from_slice(&cardinality.to_le_bytes()); + if ranges { + let mut start: Option = None; + let mut previous: Option = None; + for document in bitmap.iter() { + if previous.is_some_and(|previous| document != previous.saturating_add(1)) { + let start_value = start.ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram range is missing its start".to_owned(), + ) + })?; + let previous_value = previous.ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram range is missing its end".to_owned(), + ) + })?; + encoded.extend_from_slice(&start_value.to_le_bytes()); + encoded.extend_from_slice(&(previous_value - start_value).to_le_bytes()); + start = Some(document); + } else if start.is_none() { + start = Some(document); + } + previous = Some(document); + } + if let (Some(start), Some(previous)) = (start, previous) { + encoded.extend_from_slice(&start.to_le_bytes()); + encoded.extend_from_slice(&(previous - start).to_le_bytes()); + } + } else { + for document in bitmap.iter() { + encoded.extend_from_slice(&document.to_le_bytes()); + } + } + Ok(encoded) +} + +pub(super) fn decode_ngram_bitmap( + encoded: &[u8], +) -> Result { + let header = encoded.get(..16).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap header is truncated".to_owned(), + ) + })?; + if &header[..4] != b"TDN1" || header[5..8] != [0u8; 3] || header[4] > 1 { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap header is invalid".to_owned(), + )); + } + let cardinality = u64::from_le_bytes(header[8..16].try_into().map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap cardinality is malformed".to_owned(), + ) + })?); + let width = if header[4] == 1 { 8usize } else { 4usize }; + if !(encoded.len() - 16).is_multiple_of(width) { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap payload is truncated".to_owned(), + )); + } + let mut bitmap = RoaringBitmap::new(); + let mut previous: Option = None; + for item in encoded[16..].chunks_exact(width) { + let start = u32::from_le_bytes(item[..4].try_into().map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap document is malformed".to_owned(), + ) + })?); + let end = if width == 8 { + let run = u32::from_le_bytes(item[4..8].try_into().map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap run is malformed".to_owned(), + ) + })?); + start.checked_add(run).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap run overflowed".to_owned(), + ) + })? + } else { + start + }; + if previous.is_some_and(|previous| start <= previous) { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap documents are not strictly ordered".to_owned(), + )); + } + bitmap.insert_range(start..=end); + previous = Some(end); + } + if bitmap.len() != cardinality { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap cardinality does not verify".to_owned(), + )); + } + Ok(bitmap) +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct CodeLexicalArtifactSectionDigestV1 { @@ -219,6 +766,9 @@ pub struct CodeLexicalArtifactOccurrenceV1 { pub source_span: SourceSpan, pub logical_path: String, pub sanitized_text: BoundedSanitizedText, + pub simple_name: Option, + pub qualified_name: Option, + pub kind: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -237,6 +787,9 @@ pub(super) struct ArtifactRowV1 { pub exact_terms: Vec, pub sanitized_text: BoundedSanitizedText, pub logical_path: String, + pub symbol_simple_name: Option, + pub symbol_qualified_name: Option, + pub symbol_kind: Option, pub field_lengths: BTreeMap, pub normalized_text: String, } @@ -250,6 +803,9 @@ impl From for ArtifactRowV1 { exact_terms: row.exact_terms, sanitized_text: row.sanitized_text, logical_path: row.logical_path, + symbol_simple_name: row.symbol_simple_name, + symbol_qualified_name: row.symbol_qualified_name, + symbol_kind: row.symbol_kind, field_lengths: row.field_lengths, normalized_text: row.normalized_text, } diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs index f0904b2115..d07561ea4e 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs @@ -1,11 +1,8 @@ use std::collections::BTreeSet; -use rusqlite::{Transaction, params}; use tracedecay_code_index::production::CodeIndexExecutionControlV1; -use super::{ - ARTIFACT_DOCUMENT_SCRATCH_LIMIT_BYTES, CodeLexicalArtifactErrorV1, checkpoint, sqlite_error, -}; +use super::{ARTIFACT_DOCUMENT_SCRATCH_LIMIT_BYTES, CodeLexicalArtifactErrorV1, checkpoint}; pub(super) const NGRAM_NORMALIZED: i64 = 0; pub(super) const NGRAM_RAW_OVERRIDE: i64 = 1; @@ -60,13 +57,11 @@ fn reserve_ngram_scratch( Ok(scratch) } -pub(super) fn insert_document_ngrams( - transaction: &Transaction<'_>, - kind: i64, - document: i64, +/// Canonical bounded value projection for out-of-transaction preparation. +pub(super) fn document_ngrams( bytes: &[u8], control: &dyn CodeIndexExecutionControlV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { +) -> Result, CodeLexicalArtifactErrorV1> { let (_, scratch_bytes) = document_ngram_scratch(bytes.len())?; if scratch_bytes > ARTIFACT_DOCUMENT_SCRATCH_LIMIT_BYTES { return Err(CodeLexicalArtifactErrorV1::Contract(format!( @@ -89,23 +84,23 @@ pub(super) fn insert_document_ngrams( "lexical n-gram allocator retained {allocated_bytes} bytes beyond the {scratch_bytes}-byte scratch authority" ))); } + let mut observed = 0usize; for width in 1..=bytes.len().min(3) { - ngrams.extend(bytes.windows(width).map(pack_byte_ngram)); + for window in bytes.windows(width) { + if observed.is_multiple_of(4_096) { + checkpoint(control)?; + } + ngrams.push(pack_byte_ngram(window)); + observed = observed.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact n-gram work count overflowed".to_owned(), + ) + })?; + } } ngrams.sort_unstable(); ngrams.dedup(); - let mut statement = transaction - .prepare("INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (?1, ?2, ?3)") - .map_err(sqlite_error)?; - for (ordinal, ngram) in ngrams.into_iter().enumerate() { - if ordinal % 4_096 == 0 { - checkpoint(control)?; - } - statement - .execute(params![kind, i64::from(ngram), document]) - .map_err(sqlite_error)?; - } - Ok(()) + Ok(ngrams) } pub(super) fn query_ngrams(bytes: &[u8]) -> BTreeSet { diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs new file mode 100644 index 0000000000..413d3f20ec --- /dev/null +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs @@ -0,0 +1,750 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use roaring::RoaringBitmap; +use sha2::{Digest, Sha256}; +use tracedecay_code_index::production::{CodeIndexExecutionControlV1, VerifiedSealedLexicalPageV1}; +use tracedecay_domain::{ExactFieldV1, ManifestDigest}; + +use super::super::{ + CodeLexicalProjectionMetadataV1, ProjectedChunkV1, canonical_projected_exact_term, + exact_field_for_kind, +}; +use super::format::{ + ArtifactRowV1, BASE_SECTION_NAMES, PageBaseSectionReceiptBuilderV1, encode_exact_field, + encode_field, encode_ngram_bitmap, encode_page_base_sections_receipt, ngram_page_digest, +}; +use super::postings::{NGRAM_NORMALIZED, NGRAM_RAW_OVERRIDE, document_ngrams}; +use super::{ + CodeLexicalArtifactErrorV1, NGRAM_AGGREGATION_BYTES_PER_LOGICAL_POSTING_V1, checkpoint, +}; + +#[derive(Debug)] +pub struct PreparedCodeLexicalArtifactPageV1 { + pub(super) page_ordinal: u64, + pub(super) page_digest: ManifestDigest, + pub(super) cumulative_digest: ManifestDigest, + pub(super) chunk_count: u64, + pub(super) payload_bytes: u64, + pub(super) import_count: u64, + pub(super) import_payload_bytes: u64, + pub(super) import_dictionary_digest: ManifestDigest, + pub(super) previous_cursor: Option>, + pub(super) next_cursor: Vec, + pub(super) imports: Vec, + pub(super) documents: Vec, + pub(super) ngram_shards: Vec, + pub(super) ngram_digest: ManifestDigest, + pub(super) base_sections_receipt: Vec, + source_retained_bytes: usize, + prepared_retained_bytes: usize, + preparation_scratch_bytes: usize, + estimated_write_rows: usize, + estimated_write_bytes: usize, +} + +impl PreparedCodeLexicalArtifactPageV1 { + pub fn page_ordinal(&self) -> u64 { + self.page_ordinal + } + + pub fn chunk_count(&self) -> u64 { + self.chunk_count + } + + pub fn payload_bytes(&self) -> u64 { + self.payload_bytes + } + + pub fn source_retained_bytes(&self) -> usize { + self.source_retained_bytes + } + + pub fn retained_owned_bytes(&self) -> usize { + self.prepared_retained_bytes + } + + pub fn preparation_scratch_bytes(&self) -> usize { + self.preparation_scratch_bytes + } + + pub fn estimated_write_rows(&self) -> usize { + self.estimated_write_rows + } + + pub fn estimated_write_bytes(&self) -> usize { + self.estimated_write_bytes + } + + pub fn ledger_charge_bytes(&self) -> Result { + self.source_retained_bytes + .checked_add(self.prepared_retained_bytes) + .and_then(|bytes| bytes.checked_add(self.preparation_scratch_bytes)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical page ledger charge overflowed".to_owned(), + ) + }) + } +} + +#[derive(Debug)] +pub(super) struct PreparedImportV1 { + pub(super) canonical: Vec, + pub(super) integrity_digest: ManifestDigest, +} + +#[derive(Debug)] +pub(super) struct PreparedDocumentV1 { + pub(super) document_id: i64, + pub(super) chunk_id: String, + pub(super) row: Vec, + pub(super) term_postings: Vec, + pub(super) exact_postings: Vec<(String, Vec)>, + pub(super) integrity_digest: ManifestDigest, +} + +#[derive(Debug)] +pub(super) struct PreparedNgramShardV1 { + pub(super) kind: i64, + pub(super) ngram: i64, + pub(super) documents: Vec, + pub(super) cardinality: u64, +} + +#[derive(Debug)] +pub(super) struct PreparedTermPostingV1 { + pub(super) field: String, + pub(super) term: String, + pub(super) frequency: i64, +} + +pub(super) fn prepare_page( + metadata: &CodeLexicalProjectionMetadataV1, + page: &VerifiedSealedLexicalPageV1, + previous_cursor: Option>, + preparation_scratch_bytes: usize, + control: &dyn CodeIndexExecutionControlV1, +) -> Result { + checkpoint(control)?; + let first_document = page + .next_cursor() + .emitted_chunks() + .checked_sub(page.chunk_count()) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "sealed lexical page cursor regressed its chunk count".to_owned(), + ) + })?; + let mut documents = Vec::with_capacity(page.chunks().len()); + let mut ngram_documents = BTreeMap::<(i64, i64), RoaringBitmap>::new(); + let mut logical_ngram_postings = 0usize; + if page.symbol_displays().len() != page.chunks().len() { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "sealed lexical symbol-display cardinality does not match its chunks".to_owned(), + )); + } + for (offset, (admitted, display)) in + page.chunks().iter().zip(page.symbol_displays()).enumerate() + { + checkpoint(control)?; + let document = first_document + .checked_add(u64::try_from(offset).map_err(contract_number)?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared document id overflowed".to_owned(), + ) + })?; + let (prepared, ngrams) = prepare_document( + metadata, + i64::try_from(document).map_err(contract_number)?, + admitted.chunk(), + display.as_ref(), + control, + )?; + let document = u32::try_from(prepared.document_id).map_err(contract_number)?; + logical_ngram_postings = logical_ngram_postings + .checked_add(ngrams.len()) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram aggregation count overflowed".to_owned(), + ) + })?; + for (kind, ngram) in ngrams { + ngram_documents + .entry((kind, ngram)) + .or_default() + .insert(document); + } + documents.push(prepared); + } + let mut imports = Vec::with_capacity(page.imports().len()); + for evidence in page.imports() { + checkpoint(control)?; + let canonical = serde_json::to_vec(evidence) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + let integrity_digest = import_integrity_digest(&canonical, &canonical)?; + imports.push(PreparedImportV1 { + canonical, + integrity_digest, + }); + } + let next_cursor = page + .next_cursor() + .persisted_bytes() + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + let mut ngram_shards = Vec::with_capacity(ngram_documents.len()); + for ((kind, ngram), documents) in ngram_documents { + checkpoint(control)?; + let encoded = encode_ngram_bitmap(&documents)?; + ngram_shards.push(PreparedNgramShardV1 { + kind, + ngram, + documents: encoded, + cardinality: documents.len(), + }); + } + let ngram_digest = ngram_page_digest( + page.page_ordinal(), + ngram_shards.iter().map(|shard| { + ( + shard.kind, + shard.ngram, + shard.documents.as_slice(), + shard.cardinality, + ) + }), + )?; + let base_sections_receipt = prepare_base_sections_receipt( + page.page_ordinal(), + &imports, + &documents, + &ngram_shards, + control, + )?; + let aggregation_scratch_bytes = logical_ngram_postings + .checked_mul(NGRAM_AGGREGATION_BYTES_PER_LOGICAL_POSTING_V1) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram aggregation charge overflowed".to_owned(), + ) + })?; + let preparation_scratch_bytes = preparation_scratch_bytes + .checked_add(aggregation_scratch_bytes) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page preparation scratch charge overflowed".to_owned(), + ) + })?; + let mut prepared = PreparedCodeLexicalArtifactPageV1 { + page_ordinal: page.page_ordinal(), + page_digest: page.page_digest().clone(), + cumulative_digest: page.cumulative_digest().clone(), + chunk_count: page.chunk_count(), + payload_bytes: page.payload_bytes(), + import_count: page.import_count(), + import_payload_bytes: page.import_payload_bytes(), + import_dictionary_digest: page.next_cursor().import_dictionary_digest().clone(), + previous_cursor, + next_cursor, + imports, + documents, + ngram_shards, + ngram_digest, + base_sections_receipt, + source_retained_bytes: page.retained_owned_bytes(), + prepared_retained_bytes: 0, + preparation_scratch_bytes, + estimated_write_rows: 0, + estimated_write_bytes: 0, + }; + prepared.prepared_retained_bytes = prepared_retained_bytes(&prepared)?; + ( + prepared.estimated_write_rows, + prepared.estimated_write_bytes, + ) = estimated_sqlite_writes(&prepared)?; + Ok(prepared) +} + +fn prepare_base_sections_receipt( + page_ordinal: u64, + imports: &[PreparedImportV1], + documents: &[PreparedDocumentV1], + ngram_shards: &[PreparedNgramShardV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result, CodeLexicalArtifactErrorV1> { + let mut document_integrity = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[0])?; + let mut import_integrity = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[1])?; + let mut import_evidence = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[2])?; + let mut rows = PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[3])?; + let mut term_postings = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[4])?; + let mut exact_postings = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[5])?; + let mut ngram_postings = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[6])?; + + for import in imports { + checkpoint(control)?; + import_integrity.begin_row()?; + import_integrity.blob(&import.canonical)?; + import_integrity.text(import.integrity_digest.as_str())?; + import_evidence.begin_row()?; + import_evidence.blob(&import.canonical)?; + import_evidence.blob(&import.canonical)?; + } + for document in documents { + checkpoint(control)?; + document_integrity.begin_row()?; + document_integrity.integer(document.document_id); + document_integrity.text(&document.chunk_id)?; + document_integrity.text(document.integrity_digest.as_str())?; + + rows.begin_row()?; + rows.integer(document.document_id); + rows.text(&document.chunk_id)?; + rows.blob(&document.row)?; + + for posting in &document.term_postings { + checkpoint(control)?; + term_postings.begin_row()?; + term_postings.text(&posting.field)?; + term_postings.text(&posting.term)?; + term_postings.integer(document.document_id); + term_postings.integer(posting.frequency); + } + for (field, term) in &document.exact_postings { + checkpoint(control)?; + exact_postings.begin_row()?; + exact_postings.text(field)?; + exact_postings.blob(term)?; + exact_postings.integer(document.document_id); + } + } + for shard in ngram_shards { + checkpoint(control)?; + ngram_postings.begin_row()?; + ngram_postings.integer(i64::try_from(page_ordinal).map_err(contract_number)?); + ngram_postings.integer(shard.kind); + ngram_postings.integer(shard.ngram); + ngram_postings.blob(&shard.documents)?; + ngram_postings.integer(i64::try_from(shard.cardinality).map_err(contract_number)?); + } + + encode_page_base_sections_receipt( + page_ordinal, + vec![ + document_integrity.finish()?, + import_integrity.finish()?, + import_evidence.finish()?, + rows.finish()?, + term_postings.finish()?, + exact_postings.finish()?, + ngram_postings.finish()?, + ], + ) +} + +fn prepare_document( + metadata: &CodeLexicalProjectionMetadataV1, + document_id: i64, + chunk: &tracedecay_domain::CodeSearchChunkV1, + display: Option<&tracedecay_code_index::production::VerifiedSealedLexicalSymbolDisplayV1>, + control: &dyn CodeIndexExecutionControlV1, +) -> Result<(PreparedDocumentV1, Vec<(i64, i64)>), CodeLexicalArtifactErrorV1> { + u32::try_from(document_id).map_err(|_| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact exceeds the posting document-id range".to_owned(), + ) + })?; + if chunk.anchor.generation_id != metadata.generation { + return Err(CodeLexicalArtifactErrorV1::Contract( + "sealed lexical page contains a foreign generation".to_owned(), + )); + } + let logical_path = metadata + .logical_paths + .get(&chunk.anchor.file_occurrence_id) + .cloned() + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract(format!( + "lexical artifact metadata is missing path {}", + chunk.anchor.file_occurrence_id + )) + })?; + let (row, fields) = ProjectedChunkV1::from_ref(chunk, logical_path, display); + let mut term_postings = Vec::new(); + for (field, terms) in &fields { + checkpoint(control)?; + let encoded_field = encode_field(*field)?; + let mut frequencies = BTreeMap::<&str, u32>::new(); + for term in terms { + frequencies + .entry(term) + .and_modify(|frequency| *frequency = frequency.saturating_add(1)) + .or_insert(1); + } + for (term, frequency) in frequencies { + term_postings.push(PreparedTermPostingV1 { + field: encoded_field.clone(), + term: term.to_owned(), + frequency: i64::from(frequency), + }); + } + } + term_postings.sort_unstable_by(|left, right| { + (&left.field, &left.term).cmp(&(&right.field, &right.term)) + }); + + let mut exact_postings = BTreeSet::new(); + exact_postings.insert(( + encode_exact_field(ExactFieldV1::Path)?, + row.logical_path.as_bytes().to_vec(), + )); + let mut encoded_fields = BTreeMap::new(); + for term in &row.exact_terms { + let field = exact_field_for_kind(term.kind()); + let encoded = match encoded_fields.entry(field) { + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(encode_exact_field(field)?) + } + std::collections::btree_map::Entry::Occupied(slot) => slot.into_mut(), + }; + exact_postings.insert(( + encoded.clone(), + canonical_projected_exact_term(term).into_owned(), + )); + } + + let mut ngram_postings = document_ngrams(row.normalized_text.as_bytes(), control)? + .into_iter() + .map(|ngram| (NGRAM_NORMALIZED, i64::from(ngram))) + .collect::>(); + if row.sanitized_text.as_str().as_bytes() != row.normalized_text.as_bytes() { + ngram_postings.extend( + document_ngrams(row.sanitized_text.as_str().as_bytes(), control)? + .into_iter() + .map(|ngram| (NGRAM_RAW_OVERRIDE, i64::from(ngram))), + ); + } + let artifact_row = ArtifactRowV1::from(row); + let chunk_id = artifact_row.id.as_str().to_owned(); + let row = serde_json::to_vec(&artifact_row) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + let exact_postings = exact_postings.into_iter().collect::>(); + let integrity_digest = document_integrity_digest( + document_id, + chunk_id.as_bytes(), + &row, + &term_postings, + &exact_postings, + )?; + Ok(( + PreparedDocumentV1 { + document_id, + chunk_id, + row, + term_postings, + exact_postings, + integrity_digest, + }, + ngram_postings, + )) +} + +fn document_integrity_digest( + document: i64, + chunk_id: &[u8], + row: &[u8], + term_postings: &[PreparedTermPostingV1], + exact_postings: &[(String, Vec)], +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v3\0"); + hasher.update(document.to_le_bytes()); + hash_table(&mut hasher, "row", 1, |hasher, _| { + hash_text(hasher, chunk_id)?; + hash_blob(hasher, row) + })?; + hash_table( + &mut hasher, + "term_posting", + term_postings.len(), + |hasher, ordinal| { + let posting = &term_postings[ordinal]; + hash_text(hasher, posting.field.as_bytes())?; + hash_text(hasher, posting.term.as_bytes())?; + hash_integer(hasher, posting.frequency); + Ok(()) + }, + )?; + hash_table( + &mut hasher, + "exact_posting", + exact_postings.len(), + |hasher, ordinal| { + let (field, term) = &exact_postings[ordinal]; + hash_text(hasher, field.as_bytes())?; + hash_blob(hasher, term) + }, + )?; + integrity_digest(hasher) +} + +fn hash_table( + hasher: &mut Sha256, + table: &str, + row_count: usize, + mut hash_row: impl FnMut(&mut Sha256, usize) -> Result<(), CodeLexicalArtifactErrorV1>, +) -> Result<(), CodeLexicalArtifactErrorV1> { + hasher.update( + u64::try_from(table.len()) + .map_err(contract_number)? + .to_le_bytes(), + ); + hasher.update(table.as_bytes()); + for ordinal in 0..row_count { + hasher.update(b"row\0"); + hash_row(hasher, ordinal)?; + } + hasher.update(b"end\0"); + hasher.update( + u64::try_from(row_count) + .map_err(contract_number)? + .to_le_bytes(), + ); + Ok(()) +} + +fn hash_integer(hasher: &mut Sha256, value: i64) { + hasher.update([1]); + hasher.update(value.to_le_bytes()); +} + +fn hash_text(hasher: &mut Sha256, value: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { + hasher.update([3]); + hash_bytes(hasher, value) +} + +fn hash_blob(hasher: &mut Sha256, value: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { + hasher.update([4]); + hash_bytes(hasher, value) +} + +fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { + hasher.update( + u64::try_from(bytes.len()) + .map_err(contract_number)? + .to_le_bytes(), + ); + hasher.update(bytes); + Ok(()) +} + +fn import_integrity_digest( + canonical: &[u8], + evidence: &[u8], +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-derived-import.v1\0"); + hash_bytes(&mut hasher, canonical)?; + hash_bytes(&mut hasher, evidence)?; + integrity_digest(hasher) +} + +fn integrity_digest(hasher: Sha256) -> Result { + ManifestDigest::from_sha256_bytes(&hasher.finalize()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string())) +} + +fn prepared_retained_bytes( + page: &PreparedCodeLexicalArtifactPageV1, +) -> Result { + let mut bytes = page + .page_digest + .as_str() + .len() + .checked_add(page.cumulative_digest.as_str().len()) + .and_then(|bytes| bytes.checked_add(page.import_dictionary_digest.as_str().len())) + .and_then(|bytes| bytes.checked_add(page.next_cursor.capacity())) + .and_then(|bytes| bytes.checked_add(page.previous_cursor.as_ref().map_or(0, Vec::capacity))) + .and_then(|bytes| { + bytes.checked_add( + page.imports + .capacity() + .saturating_mul(std::mem::size_of::()), + ) + }) + .and_then(|bytes| { + bytes.checked_add( + page.documents + .capacity() + .saturating_mul(std::mem::size_of::()), + ) + }) + .and_then(|bytes| { + bytes.checked_add( + page.ngram_shards + .capacity() + .saturating_mul(std::mem::size_of::()), + ) + }) + .and_then(|bytes| bytes.checked_add(page.ngram_digest.as_str().len())) + .and_then(|bytes| bytes.checked_add(page.base_sections_receipt.capacity())) + .ok_or_else(prepared_charge_overflow)?; + for import in &page.imports { + bytes = bytes + .checked_add(import.canonical.capacity()) + .and_then(|bytes| bytes.checked_add(import.integrity_digest.as_str().len())) + .ok_or_else(prepared_charge_overflow)?; + } + for document in &page.documents { + bytes = bytes + .checked_add(document.chunk_id.capacity()) + .and_then(|bytes| bytes.checked_add(document.row.capacity())) + .and_then(|bytes| bytes.checked_add(document.integrity_digest.as_str().len())) + .and_then(|bytes| { + bytes.checked_add( + document + .term_postings + .capacity() + .saturating_mul(std::mem::size_of::()), + ) + }) + .and_then(|bytes| { + bytes.checked_add( + document + .exact_postings + .capacity() + .saturating_mul(std::mem::size_of::<(String, Vec)>()), + ) + }) + .ok_or_else(prepared_charge_overflow)?; + for posting in &document.term_postings { + bytes = bytes + .checked_add(posting.field.capacity()) + .and_then(|bytes| bytes.checked_add(posting.term.capacity())) + .ok_or_else(prepared_charge_overflow)?; + } + for (field, term) in &document.exact_postings { + bytes = bytes + .checked_add(field.capacity()) + .and_then(|bytes| bytes.checked_add(term.capacity())) + .ok_or_else(prepared_charge_overflow)?; + } + } + for shard in &page.ngram_shards { + bytes = bytes + .checked_add(shard.documents.capacity()) + .ok_or_else(prepared_charge_overflow)?; + } + Ok(bytes) +} + +fn estimated_sqlite_writes( + page: &PreparedCodeLexicalArtifactPageV1, +) -> Result<(usize, usize), CodeLexicalArtifactErrorV1> { + let mut rows = 1usize; + let mut bytes = estimated_source_page_receipt_write_bytes( + page.page_digest.as_str(), + page.cumulative_digest.as_str(), + page.import_dictionary_digest.as_str(), + page.ngram_digest.as_str(), + &page.base_sections_receipt, + &page.next_cursor, + )?; + for import in &page.imports { + rows = rows.checked_add(2).ok_or_else(prepared_write_overflow)?; + bytes = bytes + .checked_add(import.canonical.len().saturating_mul(2)) + .and_then(|bytes| bytes.checked_add(import.integrity_digest.as_str().len())) + .ok_or_else(prepared_write_overflow)?; + } + for document in &page.documents { + rows = rows.checked_add(2).ok_or_else(prepared_write_overflow)?; + bytes = bytes + .checked_add(document.chunk_id.len()) + .and_then(|bytes| bytes.checked_add(document.row.len())) + .and_then(|bytes| bytes.checked_add(document.integrity_digest.as_str().len())) + .ok_or_else(prepared_write_overflow)?; + for posting in &document.term_postings { + rows = rows.checked_add(1).ok_or_else(prepared_write_overflow)?; + bytes = bytes + .checked_add(posting.field.len().saturating_add(posting.term.len())) + .and_then(|bytes| bytes.checked_add(32)) + .ok_or_else(prepared_write_overflow)?; + } + for (field, term) in &document.exact_postings { + rows = rows.checked_add(1).ok_or_else(prepared_write_overflow)?; + bytes = bytes + .checked_add(field.len()) + .and_then(|bytes| bytes.checked_add(term.len())) + .and_then(|bytes| bytes.checked_add(8)) + .ok_or_else(prepared_write_overflow)?; + } + } + rows = rows + .checked_add(page.ngram_shards.len()) + .ok_or_else(prepared_write_overflow)?; + for shard in &page.ngram_shards { + bytes = bytes + .checked_add(shard.documents.len()) + .and_then(|bytes| bytes.checked_add(32)) + .ok_or_else(prepared_write_overflow)?; + } + Ok((rows, bytes)) +} + +fn estimated_source_page_receipt_write_bytes( + page_digest: &str, + cumulative_digest: &str, + import_dictionary_digest: &str, + ngram_digest: &str, + base_sections_receipt: &[u8], + next_cursor: &[u8], +) -> Result { + page_digest + .len() + .checked_add(cumulative_digest.len()) + .and_then(|bytes| bytes.checked_add(import_dictionary_digest.len())) + .and_then(|bytes| bytes.checked_add(ngram_digest.len())) + .and_then(|bytes| bytes.checked_add(base_sections_receipt.len())) + .and_then(|bytes| bytes.checked_add(next_cursor.len())) + .ok_or_else(prepared_write_overflow) +} + +fn prepared_write_overflow() -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical page estimated SQLite write overflowed".to_owned(), + ) +} + +fn prepared_charge_overflow() -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical page retained-byte charge overflowed".to_owned(), + ) +} + +fn contract_number(error: impl std::fmt::Display) -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::Contract(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::estimated_source_page_receipt_write_bytes; + + #[test] + fn source_page_write_charge_includes_the_ngram_receipt_exactly() { + let without_ngram = + estimated_source_page_receipt_write_bytes("p", "cc", "iii", "", b"", b"cursor") + .expect("receipt charge without ngram digest"); + let with_ngram = + estimated_source_page_receipt_write_bytes("p", "cc", "iii", "nnnnn", b"", b"cursor") + .expect("receipt charge with ngram digest"); + + assert_eq!(without_ngram, 1 + 2 + 3 + 6); + assert_eq!(with_ngram, without_ngram + 5); + } +} diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs index 67eec968d0..7acc2b5019 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs @@ -2,11 +2,13 @@ use std::cell::Cell; use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; +use std::fmt::Write as _; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; -use std::sync::{Arc, Mutex as StdMutex}; +use std::sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard}; +use roaring::RoaringBitmap; #[cfg(any(test, feature = "hotpath"))] use rusqlite::StatementStatus; use rusqlite::{Connection, OpenFlags, OptionalExtension, params_from_iter, types::Value}; @@ -25,7 +27,7 @@ use super::builder::compute_section_digests; use super::format::{ ArtifactRowV1, CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1, CodeLexicalArtifactOccurrenceV1, CodeLexicalImportMembershipWitnessV1, VerifiedCodeLexicalArtifactV1, artifact_digest, - decode_padded_receipt, encode_exact_field, encode_field, metadata_digest, + decode_ngram_bitmap, decode_padded_receipt, encode_exact_field, encode_field, metadata_digest, verify_required_artifact_indexes, }; use super::postings::{NGRAM_NORMALIZED, NGRAM_RAW_OVERRIDE, query_ngrams}; @@ -59,16 +61,8 @@ pub struct CodeLexicalArtifactReaderV1 { retained_owned_bytes: usize, } -#[cfg(feature = "hotpath")] -type ArtifactConnectionMutex = hotpath::mutexes::Mutex; -#[cfg(not(feature = "hotpath"))] type ArtifactConnectionMutex = StdMutex; -#[cfg(feature = "hotpath")] -type ArtifactConnectionMutexGuard<'a, T> = hotpath::mutexes::MutexGuard<'a, T>; -#[cfg(not(feature = "hotpath"))] -type ArtifactConnectionMutexGuard<'a, T> = std::sync::MutexGuard<'a, T>; - impl std::fmt::Debug for CodeLexicalArtifactReaderV1 { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter @@ -321,14 +315,11 @@ impl CodeLexicalArtifactReaderV1 { checkpoint(control)?; let retained_owned_bytes = stored_metadata_bytes.len().saturating_add(page_cache_bytes); Ok(Self { - // Every clone shares one rusqlite handle. The instrumented mutex - // exposes both shared-reader coordination wait and the full - // serialized SQL hold rather than attributing that time to the - // outer lane alone. This is one handle, not a connection pool. - connection: Arc::new(hotpath::mutex!( - StdMutex::new(connection), - label = "query.artifact.connection" - )), + // Every clone shares one rusqlite handle. Readers are replaced on + // remount, while Hotpath 0.24 retains every instrumented mutex + // identity for the process lifetime, so this per-reader lock must + // remain plain. Static query spans retain operation visibility. + connection: Arc::new(StdMutex::new(connection)), metadata, receipt: stored, retained_owned_bytes, @@ -432,9 +423,7 @@ impl CodeLexicalArtifactReaderV1 { } } - fn lock_connection( - &self, - ) -> Result, CodeLexicalArtifactErrorV1> { + fn lock_connection(&self) -> Result, CodeLexicalArtifactErrorV1> { self.connection.lock().map_err(|_| { CodeLexicalArtifactErrorV1::Io("lexical artifact reader lock is poisoned".to_owned()) }) @@ -533,6 +522,10 @@ struct ArtifactQueryMetricsV1 { probes: Cell, #[cfg(test)] fullscan_steps: Cell, + #[cfg(test)] + ngram_decoded_shards: Cell, + #[cfg(test)] + ngram_peak_candidates: Cell, } impl ArtifactQueryMetricsV1 { @@ -579,6 +572,18 @@ impl ArtifactQueryMetricsV1 { fn observed_fullscan_steps(&self) -> u64 { self.fullscan_steps.get() } + + #[cfg(test)] + fn observe_ngram_shard(&self) { + self.ngram_decoded_shards + .set(self.ngram_decoded_shards.get().saturating_add(1)); + } + + #[cfg(test)] + fn observe_ngram_candidates(&self, candidates: u64) { + self.ngram_peak_candidates + .set(self.ngram_peak_candidates.get().max(candidates)); + } } /// A SQLite-owned candidate set. The query is evaluated row-by-row, so Rust @@ -589,6 +594,7 @@ impl ArtifactQueryMetricsV1 { struct DocumentQueryV1 { sql: Option, parameters: Vec, + maximum_bound_value_bytes: usize, } impl DocumentQueryV1 { @@ -596,6 +602,7 @@ impl DocumentQueryV1 { Self { sql: None, parameters: Vec::new(), + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, } } @@ -605,6 +612,7 @@ impl DocumentQueryV1 { "SELECT document_id FROM term_postings WHERE field = ? AND term = ?".to_owned(), ), parameters: vec![Value::Text(field), Value::Text(term)], + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, } } @@ -614,6 +622,7 @@ impl DocumentQueryV1 { "SELECT document_id FROM term_postings WHERE term = ? AND field != ?".to_owned(), ), parameters: vec![Value::Text(term), Value::Text(excluded_field)], + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, } } @@ -623,6 +632,7 @@ impl DocumentQueryV1 { "SELECT document_id FROM exact_postings WHERE field = ? AND term = ?".to_owned(), ), parameters: vec![Value::Text(field), Value::Blob(term)], + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, } } } @@ -632,50 +642,96 @@ impl DocumentQueryV1 { /// the only storage used for the set-operation work table. const ARTIFACT_UNION_COMPOUND_ARMS_V1: usize = 64; -fn union_document_queries(queries: impl IntoIterator) -> DocumentQueryV1 { - let mut level = queries +fn union_document_queries( + queries: impl IntoIterator, +) -> Result { + let queries = queries .into_iter() .filter(|query| query.sql.is_some()) .collect::>(); - if level.is_empty() { - return DocumentQueryV1::empty(); - } + if queries.is_empty() { + return Ok(DocumentQueryV1::empty()); + } + + // Keep each compound arm below SQLite's expression limit, while sharing + // equal bind values across arms. A large fuzzy/phrase request commonly + // repeats its encoded field, so retaining one bind slot for every textual + // occurrence needlessly crosses the portable 999-variable ceiling even + // though the request's distinct values remain bounded. Named parameters + // also remain safe when this query is embedded in the frequency probe. + let maximum_bound_value_bytes = queries + .iter() + .map(|query| query.maximum_bound_value_bytes) + .max() + .unwrap_or(ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1); + let mut parameters = Vec::new(); + let mut level = queries + .into_iter() + .map(|query| { + let Some(sql) = query.sql else { + return Ok(String::new()); + }; + let sql = rewrite_union_query_parameters(&sql, &query.parameters, &mut parameters)?; + Ok(format!("SELECT document_id FROM ({sql})")) + }) + .collect::, RetrievalPortError>>()? + .into_iter() + .filter(|query| !query.is_empty()) + .collect::>(); while level.len() > 1 { level = level .chunks(ARTIFACT_UNION_COMPOUND_ARMS_V1) - .map(compound_union_document_queries) + .map(|queries| { + let sql = queries.join(" UNION ALL "); + format!("SELECT document_id FROM ({sql})") + }) .collect(); } let Some(root) = level.pop() else { - return DocumentQueryV1::empty(); + return Ok(DocumentQueryV1::empty()); }; - DocumentQueryV1 { - sql: root - .sql - .map(|sql| format!("SELECT DISTINCT document_id FROM ({sql}) ORDER BY document_id")), - parameters: root.parameters, - } + Ok(DocumentQueryV1 { + sql: Some(format!( + "SELECT DISTINCT document_id FROM ({root}) ORDER BY document_id" + )), + parameters, + maximum_bound_value_bytes, + }) } -fn compound_union_document_queries(queries: &[DocumentQueryV1]) -> DocumentQueryV1 { - let mut sql = String::new(); - let mut parameters = Vec::new(); - for query in queries { - let Some(query_sql) = query.sql.as_deref() else { +fn rewrite_union_query_parameters( + sql: &str, + query_parameters: &[Value], + parameters: &mut Vec, +) -> Result { + let mut rewritten = String::with_capacity(sql.len()); + let mut parameter_ordinal = 0usize; + for character in sql.chars() { + if character != '?' { + rewritten.push(character); continue; - }; - if !sql.is_empty() { - sql.push_str(" UNION ALL "); } - sql.push_str("SELECT document_id FROM ("); - sql.push_str(query_sql); - sql.push(')'); - parameters.extend(query.parameters.iter().cloned()); + let Some(value) = query_parameters.get(parameter_ordinal) else { + return Err(RetrievalPortError::Contract( + "document query SQL has more bind placeholders than values".to_owned(), + )); + }; + let slot = if let Some(slot) = parameters.iter().position(|candidate| candidate == value) { + slot + } else { + parameters.push(value.clone()); + parameters.len() - 1 + }; + rewritten.push_str(":d"); + rewritten.push_str(&slot.to_string()); + parameter_ordinal += 1; } - DocumentQueryV1 { - sql: Some(sql), - parameters, + if parameter_ordinal != query_parameters.len() { + return Err(RetrievalPortError::Contract( + "document query has values without bind placeholders".to_owned(), + )); } + Ok(rewritten) } fn visit_document_ids( @@ -688,7 +744,11 @@ fn visit_document_ids( return Ok(()); }; ensure_sqlite_bind_capacity(0, query.parameters.len())?; - ensure_sqlite_bound_value_bytes(&query.parameters, std::iter::empty())?; + ensure_sqlite_bound_value_bytes( + query.maximum_bound_value_bytes, + &query.parameters, + std::iter::empty(), + )?; let mut statement = connection.prepare(sql).map_err(map_query_sql_error)?; let mut rows = statement .query(params_from_iter(query.parameters.iter())) @@ -719,7 +779,11 @@ fn visit_lexical_rows( return Ok(()); }; ensure_sqlite_bind_capacity(documents.parameters.len(), terms.len())?; - ensure_sqlite_bound_value_bytes(&documents.parameters, terms.iter().map(String::as_str))?; + ensure_sqlite_bound_value_bytes( + documents.maximum_bound_value_bytes, + &documents.parameters, + terms.iter().map(String::as_str), + )?; let mut parameters = Vec::with_capacity(documents.parameters.len().saturating_add(terms.len())); let frequencies = if terms.is_empty() { @@ -788,6 +852,34 @@ const ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1: usize = 999; /// individual SQLite call deterministically bounded instead. const ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1: usize = ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1 * MAX_LEXICAL_QUERY_TERM_BYTES_V1; +/// A phrase prefilter may legitimately name more documents than request text +/// can occupy. Keep its one transient JSON1 bridge distinct from the generic +/// query-input bound and below one eighth of the reader cache authority. +const ARTIFACT_NGRAM_CANDIDATE_JSON_BYTES_V1: usize = + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1 / 8; +/// Bitmap queries may inspect only this many source-page shards per port call. +/// The read port carries no execution-control handle, so this fixed work bound +/// is the cancellation/deadline yield authority before control returns to the +/// caller. A 4 KiB work unit leaves authority for blob decode and intersection. +const ARTIFACT_NGRAM_QUERY_MAX_SHARDS_V1: usize = + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1 / (4 * 1024); +/// One encoded source-page shard is retained only while it is decoded and +/// intersected. Keep that transient allocation below one eighth of the cache. +const ARTIFACT_NGRAM_MAX_ENCODED_SHARD_BYTES_V1: usize = + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1 / 8; +/// The synchronous query may inspect at most one quarter of the reader cache +/// in encoded shard bytes across all selected n-grams. +const ARTIFACT_NGRAM_QUERY_ENCODED_BYTES_V1: usize = + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1 / 4; +/// A sparse Roaring candidate can require containers and two-byte values in +/// addition to its identifiers. Eight bytes per admitted identifier is a +/// conservative authority that bounds the first (rarest) full union. +const ARTIFACT_NGRAM_CANDIDATE_BITMAP_BYTES_V1: usize = + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1 / 4; +const ARTIFACT_NGRAM_CANDIDATE_BYTES_PER_DOCUMENT_V1: usize = 8; +const ARTIFACT_NGRAM_MAX_CANDIDATES_V1: u64 = (ARTIFACT_NGRAM_CANDIDATE_BITMAP_BYTES_V1 + / ARTIFACT_NGRAM_CANDIDATE_BYTES_PER_DOCUMENT_V1) + as u64; fn ensure_sqlite_bind_capacity( fixed_parameters: usize, @@ -803,6 +895,7 @@ fn ensure_sqlite_bind_capacity( } fn ensure_sqlite_bound_value_bytes<'a>( + maximum_bytes: usize, fixed_parameters: &[Value], dynamic_text: impl IntoIterator, ) -> Result<(), RetrievalPortError> { @@ -823,7 +916,7 @@ fn ensure_sqlite_bound_value_bytes<'a>( .checked_add(value.len()) .ok_or(RetrievalPortError::BudgetExceeded) })?; - if total_bytes > ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1 { + if total_bytes > maximum_bytes { return Err(RetrievalPortError::BudgetExceeded); } Ok(()) @@ -832,30 +925,226 @@ fn ensure_sqlite_bound_value_bytes<'a>( /// The first fixed number of distinct n-grams forms a selective, bounded /// prefilter. It may admit a superset for a very long phrase; the row-level /// substring check remains the correctness authority before scoring. -fn ngram_document_query(kind: i64, bytes: &[u8]) -> DocumentQueryV1 { - let ngrams = query_ngrams(bytes) - .into_iter() - .take(ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1) - .collect::>(); - if ngrams.is_empty() { - return DocumentQueryV1::empty(); - } - let placeholders = std::iter::repeat_n("?", ngrams.len()) - .collect::>() - .join(", "); - let mut parameters = Vec::with_capacity(ngrams.len() + 2); - parameters.push(Value::Integer(kind)); - parameters.extend(ngrams.iter().map(|ngram| Value::Integer(i64::from(*ngram)))); - parameters.push(Value::Integer(ngrams.len() as i64)); - DocumentQueryV1 { - sql: Some(format!( - "SELECT document_id FROM ngram_postings \ - WHERE kind = ? AND ngram IN ({placeholders}) \ - GROUP BY document_id HAVING COUNT(DISTINCT ngram) = ? \ - ORDER BY document_id" - )), - parameters, +fn ngram_document_query( + connection: &Connection, + kind: i64, + bytes: &[u8], + metrics: &ArtifactQueryMetricsV1, +) -> Result { + hotpath::measure_block!("query.artifact.ngram.bitmap_query", { + let ngrams = query_ngrams(bytes) + .into_iter() + .take(ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1) + .collect::>(); + if ngrams.is_empty() { + return Ok(DocumentQueryV1::empty()); + } + let candidates = ngram_bitmap_candidates(connection, kind, &ngrams, metrics)?; + let encoded = + encode_ngram_candidate_json(&candidates, ARTIFACT_NGRAM_CANDIDATE_JSON_BYTES_V1)?; + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.ngram.query_candidates_total").inc(candidates.len()); + Ok(DocumentQueryV1 { + sql: Some( + "SELECT CAST(value AS INTEGER) AS document_id FROM json_each(?) ORDER BY document_id" + .to_owned(), + ), + parameters: vec![Value::Text(encoded)], + maximum_bound_value_bytes: ARTIFACT_NGRAM_CANDIDATE_JSON_BYTES_V1, + }) + }) +} + +#[derive(Clone, Copy)] +struct NgramSelectivityV1 { + ngram: u32, + cardinality: u64, +} + +fn ngram_bitmap_candidates( + connection: &Connection, + kind: i64, + ngrams: &[u32], + _metrics: &ArtifactQueryMetricsV1, +) -> Result { + let mut remaining_shards = ARTIFACT_NGRAM_QUERY_MAX_SHARDS_V1; + let mut remaining_encoded_bytes = ARTIFACT_NGRAM_QUERY_ENCODED_BYTES_V1; + let mut selectivities = Vec::with_capacity(ngrams.len()); + let mut selectivity_statement = connection + .prepare( + "SELECT cardinality, length(documents) FROM ngram_postings INDEXED BY ngram_postings_by_ngram WHERE kind = ?1 AND ngram = ?2 ORDER BY page_ordinal LIMIT ?3", + ) + .map_err(map_query_sql_error)?; + for ngram in ngrams { + let row_limit = remaining_shards + .checked_add(1) + .ok_or(RetrievalPortError::BudgetExceeded)?; + let mut rows = selectivity_statement + .query([ + kind, + i64::from(*ngram), + i64::try_from(row_limit).map_err(contract_error)?, + ]) + .map_err(map_query_sql_error)?; + let mut cardinality = 0u64; + let mut observed_shards = 0usize; + while let Some(row) = rows.next().map_err(map_query_sql_error)? { + if observed_shards == remaining_shards { + return Err(RetrievalPortError::BudgetExceeded); + } + let shard_cardinality: i64 = row.get(0).map_err(map_query_sql_error)?; + let encoded_bytes: i64 = row.get(1).map_err(map_query_sql_error)?; + let shard_cardinality = u64::try_from(shard_cardinality).map_err(contract_error)?; + let encoded_bytes = usize::try_from(encoded_bytes).map_err(contract_error)?; + if shard_cardinality == 0 { + return Err(RetrievalPortError::BudgetExceeded); + } + charge_ngram_encoded_shard_bytes( + &mut remaining_encoded_bytes, + encoded_bytes, + ARTIFACT_NGRAM_MAX_ENCODED_SHARD_BYTES_V1, + )?; + cardinality = cardinality + .checked_add(shard_cardinality) + .ok_or(RetrievalPortError::BudgetExceeded)?; + observed_shards = observed_shards + .checked_add(1) + .ok_or(RetrievalPortError::BudgetExceeded)?; + } + drop(rows); + if cardinality == 0 { + return Ok(RoaringBitmap::new()); + } + remaining_shards = remaining_shards + .checked_sub(observed_shards) + .ok_or(RetrievalPortError::BudgetExceeded)?; + selectivities.push(NgramSelectivityV1 { + ngram: *ngram, + cardinality, + }); + } + drop(selectivity_statement); + selectivities.sort_unstable_by_key(|selectivity| (selectivity.cardinality, selectivity.ngram)); + if let Some(selectivity) = selectivities.first() { + ensure_ngram_candidate_cardinality(selectivity.cardinality)?; + } + + let mut candidates = None::; + #[cfg(feature = "hotpath")] + let mut observed_shards = 0u64; + #[cfg(feature = "hotpath")] + let mut observed_bytes = 0u64; + let mut statement = connection + .prepare( + "SELECT documents, cardinality FROM ngram_postings INDEXED BY ngram_postings_by_ngram WHERE kind = ?1 AND ngram = ?2 ORDER BY page_ordinal", + ) + .map_err(map_query_sql_error)?; + for selectivity in selectivities { + let mut documents = RoaringBitmap::new(); + let mut rows = statement + .query([kind, i64::from(selectivity.ngram)]) + .map_err(map_query_sql_error)?; + while let Some(row) = rows.next().map_err(map_query_sql_error)? { + let encoded: Vec = row.get(0).map_err(map_query_sql_error)?; + let cardinality: i64 = row.get(1).map_err(map_query_sql_error)?; + let shard = decode_ngram_bitmap(&encoded).map_err(map_query_artifact_error)?; + if i64::try_from(shard.len()).map_err(contract_error)? != cardinality { + return Err(RetrievalPortError::Contract( + "lexical artifact ngram shard cardinality changed after verification" + .to_owned(), + )); + } + if let Some(candidates) = candidates.as_ref() { + documents |= &shard & candidates; + } else { + documents |= shard; + } + ensure_ngram_candidate_cardinality(documents.len())?; + #[cfg(test)] + { + _metrics.observe_ngram_shard(); + _metrics.observe_ngram_candidates(documents.len()); + } + #[cfg(feature = "hotpath")] + { + observed_shards = observed_shards.saturating_add(1); + observed_bytes = observed_bytes.saturating_add(encoded.len() as u64); + } + } + drop(rows); + candidates = Some(documents); + if candidates.as_ref().is_none_or(RoaringBitmap::is_empty) { + break; + } + } + let candidates = candidates.unwrap_or_default(); + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.ngram.query_shards_total").inc(observed_shards); + hotpath::gauge!("query.artifact.ngram.query_bytes_total").inc(observed_bytes); } + Ok(candidates) +} + +fn ensure_ngram_candidate_cardinality(cardinality: u64) -> Result<(), RetrievalPortError> { + if cardinality > ARTIFACT_NGRAM_MAX_CANDIDATES_V1 { + Err(RetrievalPortError::BudgetExceeded) + } else { + Ok(()) + } +} + +fn charge_ngram_encoded_shard_bytes( + remaining_bytes: &mut usize, + shard_bytes: usize, + maximum_shard_bytes: usize, +) -> Result<(), RetrievalPortError> { + if shard_bytes > maximum_shard_bytes { + return Err(RetrievalPortError::BudgetExceeded); + } + *remaining_bytes = remaining_bytes + .checked_sub(shard_bytes) + .ok_or(RetrievalPortError::BudgetExceeded)?; + Ok(()) +} + +fn encode_ngram_candidate_json( + candidates: &RoaringBitmap, + maximum_bytes: usize, +) -> Result { + if maximum_bytes < 2 { + return Err(RetrievalPortError::BudgetExceeded); + } + let capacity = usize::try_from(candidates.len()) + .map_err(contract_error)? + .checked_mul(11) + .and_then(|bytes| bytes.checked_add(2)) + .ok_or(RetrievalPortError::BudgetExceeded)? + .min(maximum_bytes); + let mut encoded = String::with_capacity(capacity); + encoded.push('['); + for (ordinal, document) in candidates.iter().enumerate() { + let digits = if document == 0 { + 1 + } else { + usize::try_from(document.ilog10()).map_err(contract_error)? + 1 + }; + let additional = digits + usize::from(ordinal != 0); + if encoded + .len() + .checked_add(additional) + .and_then(|bytes| bytes.checked_add(1)) + .is_none_or(|bytes| bytes > maximum_bytes) + { + return Err(RetrievalPortError::BudgetExceeded); + } + if ordinal != 0 { + encoded.push(','); + } + write!(&mut encoded, "{document}").map_err(contract_error)?; + } + encoded.push(']'); + Ok(encoded) } impl<'a> ArtifactQueryV1<'a> { @@ -880,21 +1169,23 @@ impl<'a> ArtifactQueryV1<'a> { let fuzzy = self.fuzzy_expansions(request)?; let terms = lexical_terms(request, &fuzzy); let stats = self.lexical_stats(&terms)?; - let phrase_queries = request - .phrases - .iter() - .map(|phrase| { - let normalized = normalize_lexical(phrase); - let query = ngram_document_query(NGRAM_NORMALIZED, normalized.as_bytes()); - (normalized, query) - }) - .collect::>(); + let mut phrase_queries = BTreeMap::new(); + for phrase in &request.phrases { + let normalized = normalize_lexical(phrase); + let query = ngram_document_query( + self.connection, + NGRAM_NORMALIZED, + normalized.as_bytes(), + &self.metrics, + )?; + phrase_queries.insert(normalized, query); + } let mut phrase_frequencies = phrase_queries .keys() .cloned() .map(|phrase| (phrase, 0usize)) .collect::>(); - let phrase_documents = union_document_queries(phrase_queries.values().cloned()); + let phrase_documents = union_document_queries(phrase_queries.values().cloned())?; visit_lexical_rows( self.connection, &phrase_documents, @@ -1127,7 +1418,7 @@ impl<'a> ArtifactQueryV1<'a> { )); } sources.extend(phrase_queries.values().cloned()); - Ok(union_document_queries(sources)) + union_document_queries(sources) } fn exact_documents( @@ -1143,13 +1434,17 @@ impl<'a> ArtifactQueryV1<'a> { | ExactFieldV1::CompilerOrRuntimeError ) { sources.push(ngram_document_query( + self.connection, NGRAM_NORMALIZED, &literal.original_bytes, - )); + &self.metrics, + )?); sources.push(ngram_document_query( + self.connection, NGRAM_RAW_OVERRIDE, &literal.original_bytes, - )); + &self.metrics, + )?); } let field = encode_exact_field(literal.field).map_err(map_query_artifact_error)?; sources.push(DocumentQueryV1::exact( @@ -1157,7 +1452,7 @@ impl<'a> ArtifactQueryV1<'a> { literal.canonical_bytes.clone(), )); } - Ok(union_document_queries(sources)) + union_document_queries(sources) } fn visit_documents( @@ -1272,7 +1567,11 @@ impl<'a> ArtifactQueryV1<'a> { terms: &BTreeSet, ) -> Result { ensure_sqlite_bind_capacity(0, terms.len())?; - ensure_sqlite_bound_value_bytes(&[], terms.iter().map(String::as_str))?; + ensure_sqlite_bound_value_bytes( + ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, + &[], + terms.iter().map(String::as_str), + )?; let mut field_totals = BTreeMap::new(); self.metrics.probe(); let mut statement = self @@ -1993,6 +2292,9 @@ fn row_occurrence(row: ArtifactRowV1) -> CodeLexicalArtifactOccurrenceV1 { source_span: row.anchor.source_span, logical_path: row.logical_path, sanitized_text: row.sanitized_text, + simple_name: row.symbol_simple_name, + qualified_name: row.symbol_qualified_name, + kind: row.symbol_kind, } } @@ -2005,7 +2307,8 @@ fn map_query_artifact_error(error: CodeLexicalArtifactErrorV1) -> RetrievalPortE CodeLexicalArtifactErrorV1::Interrupted(_) => RetrievalPortError::Cancelled, CodeLexicalArtifactErrorV1::Incompatible(_) => RetrievalPortError::IncompatibleProjection, CodeLexicalArtifactErrorV1::Contract(error) => RetrievalPortError::Contract(error), - CodeLexicalArtifactErrorV1::Unreserved(_) => RetrievalPortError::BudgetExceeded, + CodeLexicalArtifactErrorV1::Unreserved(_) + | CodeLexicalArtifactErrorV1::BatchTooLarge { .. } => RetrievalPortError::BudgetExceeded, CodeLexicalArtifactErrorV1::Corrupt(error) | CodeLexicalArtifactErrorV1::Io(error) | CodeLexicalArtifactErrorV1::Missing(error) => { @@ -2019,17 +2322,25 @@ mod tests { use std::cmp::Reverse; use std::collections::{BTreeSet, BinaryHeap}; use std::path::PathBuf; + #[cfg(feature = "hotpath")] + use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicUsize, Ordering}; + use roaring::RoaringBitmap; use rusqlite::{Connection, params}; use tracedecay_domain::ManifestDigest; use tracedecay_private_fs::open_private_file; + use super::super::format::encode_ngram_bitmap; + #[cfg(feature = "hotpath")] + use super::ArtifactConnectionMutex; use super::{ - ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1, ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1, - ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, ArtifactQueryMetricsV1, - CodeLexicalArtifactErrorV1, CodeLexicalArtifactReaderV1, DocumentQueryV1, NGRAM_NORMALIZED, - map_query_artifact_error, ngram_document_query, query_ngrams, retain_bounded, + ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1, ARTIFACT_NGRAM_MAX_CANDIDATES_V1, + ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1, ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, + ArtifactQueryMetricsV1, CodeLexicalArtifactErrorV1, CodeLexicalArtifactReaderV1, + DocumentQueryV1, NGRAM_NORMALIZED, charge_ngram_encoded_shard_bytes, + encode_ngram_candidate_json, ensure_ngram_candidate_cardinality, map_query_artifact_error, + ngram_bitmap_candidates, ngram_document_query, query_ngrams, retain_bounded, union_document_queries, visit_document_ids, visit_lexical_rows, }; use tracedecay_code_index::production::CodeIndexExecutionControlV1; @@ -2093,6 +2404,23 @@ mod tests { documents } + #[cfg(feature = "hotpath")] + #[test] + fn repeated_feature_on_reader_connections_use_plain_mutexes_and_preserve_queries() { + for expected in 0..16i64 { + let connection: ArtifactConnectionMutex = + StdMutex::new(Connection::open_in_memory().expect("in-memory SQLite")); + + let value = connection + .lock() + .expect("reader connection lock") + .query_row("SELECT ?1", [expected], |row| row.get::<_, i64>(0)) + .expect("query through reader connection lock"); + + assert_eq!(value, expected); + } + } + #[test] fn invalid_content_addressed_budget_is_rejected_before_path_touch() { let missing = std::env::temp_dir().join(format!( @@ -2249,10 +2577,14 @@ mod tests { connection .execute_batch( "CREATE TABLE ngram_postings ( + page_ordinal INTEGER NOT NULL, kind INTEGER NOT NULL, ngram INTEGER NOT NULL, - document_id INTEGER NOT NULL - );", + documents BLOB NOT NULL, + cardinality INTEGER NOT NULL, + PRIMARY KEY(page_ordinal, kind, ngram) + ) WITHOUT ROWID; + CREATE UNIQUE INDEX ngram_postings_by_ngram ON ngram_postings(kind, ngram, page_ordinal);", ) .expect("ngram fixture schema"); let phrase = b"abcdefghijklmnopqrstuvw"; @@ -2262,29 +2594,129 @@ mod tests { .collect::>(); assert_eq!(ngrams.len(), ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1); for (ordinal, ngram) in ngrams.iter().enumerate() { + let documents = if ordinal + 1 < ngrams.len() { + RoaringBitmap::from_iter([1, 2]) + } else { + RoaringBitmap::from_iter([1]) + }; + let encoded = encode_ngram_bitmap(&documents).expect("encode ngram shard"); connection .execute( - "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (?1, ?2, 1)", - params![NGRAM_NORMALIZED, i64::from(*ngram)], + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (0, ?1, ?2, ?3, ?4)", + params![NGRAM_NORMALIZED, i64::from(*ngram), encoded, documents.len() as i64], ) .expect("complete phrase posting"); - if ordinal + 1 < ngrams.len() { - connection - .execute( - "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (?1, ?2, 2)", - params![NGRAM_NORMALIZED, i64::from(*ngram)], - ) - .expect("incomplete phrase posting"); - } } - let query = ngram_document_query(NGRAM_NORMALIZED, phrase); + let metrics = ArtifactQueryMetricsV1::default(); + let query = ngram_document_query(&connection, NGRAM_NORMALIZED, phrase, &metrics) + .expect("build ngram bitmap query"); + + assert_eq!(query.parameters.len(), 1); + assert_eq!(streamed_documents(&connection, &query), vec![1]); + } + + #[test] + fn ngram_bitmap_query_processes_rare_shards_first_and_short_circuits_common_work() { + let connection = Connection::open_in_memory().expect("in-memory SQLite"); + connection + .execute_batch( + "CREATE TABLE ngram_postings ( + page_ordinal INTEGER NOT NULL, + kind INTEGER NOT NULL, + ngram INTEGER NOT NULL, + documents BLOB NOT NULL, + cardinality INTEGER NOT NULL, + PRIMARY KEY(page_ordinal, kind, ngram) + ) WITHOUT ROWID; + CREATE UNIQUE INDEX ngram_postings_by_ngram ON ngram_postings(kind, ngram, page_ordinal);", + ) + .expect("ngram fixture schema"); + for (page_ordinal, ngram, documents) in [ + (0i64, 10u32, vec![1u32, 2]), + (1, 10, vec![3, 4]), + (2, 10, vec![5, 6]), + (0, 20, vec![2]), + (1, 30, vec![3]), + ] { + let bitmap = RoaringBitmap::from_iter(documents); + let encoded = encode_ngram_bitmap(&bitmap).expect("encode ngram shard"); + connection + .execute( + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (?1, ?2, ?3, ?4, ?5)", + params![page_ordinal, NGRAM_NORMALIZED, i64::from(ngram), encoded, bitmap.len() as i64], + ) + .expect("seed ngram shard"); + } + + let bounded_metrics = ArtifactQueryMetricsV1::default(); + let matching = + ngram_bitmap_candidates(&connection, NGRAM_NORMALIZED, &[10, 20], &bounded_metrics) + .expect("intersect common and rare shards"); + assert_eq!(matching.iter().collect::>(), [2]); + assert_eq!(bounded_metrics.ngram_peak_candidates.get(), 1); + assert_eq!(bounded_metrics.ngram_decoded_shards.get(), 4); + let short_circuit_metrics = ArtifactQueryMetricsV1::default(); + let empty = ngram_bitmap_candidates( + &connection, + NGRAM_NORMALIZED, + &[10, 20, 30], + &short_circuit_metrics, + ) + .expect("short-circuit disjoint rare shards"); + assert!(empty.is_empty()); + assert_eq!(short_circuit_metrics.ngram_peak_candidates.get(), 1); assert_eq!( - query.parameters.len(), - ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1 + 2 + short_circuit_metrics.ngram_decoded_shards.get(), + 2, + "the three-page common ngram must not be decoded after rare shards empty the candidate set" + ); + } + + #[test] + fn ngram_candidate_json_honors_its_distinct_transient_byte_authority() { + let candidates = RoaringBitmap::from_iter([1, 20, 300]); + let exact = "[1,20,300]"; + assert_eq!( + encode_ngram_candidate_json(&candidates, exact.len()) + .expect("exact candidate JSON boundary"), + exact + ); + assert_eq!( + encode_ngram_candidate_json(&candidates, exact.len() - 1), + Err(crate::retrieval::ports::RetrievalPortError::BudgetExceeded) + ); + } + + #[test] + fn ngram_candidate_bitmap_honors_its_reader_memory_authority() { + assert_eq!( + ensure_ngram_candidate_cardinality(ARTIFACT_NGRAM_MAX_CANDIDATES_V1), + Ok(()) + ); + assert_eq!( + ensure_ngram_candidate_cardinality(ARTIFACT_NGRAM_MAX_CANDIDATES_V1 + 1), + Err(crate::retrieval::ports::RetrievalPortError::BudgetExceeded) + ); + } + + #[test] + fn ngram_query_rejects_cumulative_encoded_shards_past_its_authority() { + let mut remaining = 40usize; + for _ in 0..8 { + charge_ngram_encoded_shard_bytes(&mut remaining, 5, 5) + .expect("individually valid encoded shard"); + } + assert_eq!(remaining, 0); + assert_eq!( + charge_ngram_encoded_shard_bytes(&mut remaining, 1, 5), + Err(crate::retrieval::ports::RetrievalPortError::BudgetExceeded) + ); + assert_eq!( + remaining, 0, + "a refused shard must not consume the retained query authority" ); - assert_eq!(streamed_documents(&connection, &query), vec![1]); } #[test] @@ -2316,7 +2748,8 @@ mod tests { DocumentQueryV1::term_except("render".to_owned(), "subtoken".to_owned()), DocumentQueryV1::term_except("renderer".to_owned(), "subtoken".to_owned()), DocumentQueryV1::term("subtoken".to_owned(), "render".to_owned()), - ]); + ]) + .expect("small document union"); assert_eq!(streamed_documents(&connection, &query), vec![1, 2, 3]); assert_eq!( @@ -2325,7 +2758,8 @@ mod tests { &union_document_queries([DocumentQueryV1::term( "subtoken".to_owned(), "render".to_owned(), - )]), + )]) + .expect("single document union"), ), vec![3], "one source query must preserve bitmap-like candidate deduplication" @@ -2358,7 +2792,12 @@ mod tests { }) .collect::>(); - let query = union_document_queries(sources); + let query = union_document_queries(sources).expect("large document union"); + assert_eq!( + query.parameters.len(), + source_count as usize + 1, + "repeated field binds share one bounded SQLite parameter slot" + ); assert_eq!( streamed_documents(&connection, &query), @@ -2469,6 +2908,7 @@ mod tests { let documents = DocumentQueryV1 { sql: Some("SELECT ? AS document_id".to_owned()), parameters: vec![rusqlite::types::Value::Integer(1)], + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, }; let terms = (0..ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1) .map(|term| format!("term-{term}")) @@ -2495,6 +2935,7 @@ mod tests { let documents = DocumentQueryV1 { sql: Some("SELECT 1 AS document_id".to_owned()), parameters: Vec::new(), + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, }; let per_term_bytes = ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1 / ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1 + 1; diff --git a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs index 21727513b9..6b62ccb5da 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -1,8 +1,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::io::Cursor; +use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use sha2::{Digest, Sha256}; @@ -18,9 +19,10 @@ use tracedecay_code_index::production::{ CodeIndexExecutionControlV1, CodeIndexGenerationScopeV1, CodeIndexInterruptionV1, CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, - CodeIndexRepositoryParseIdentityV1, VerifiedSealedLexicalPageReadV1, + CodeIndexRepositoryParseIdentityV1, VerifiedSealedLexicalPageBatchBoundsV1, + VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, - VerifiedSealedLexicalSourceReceiptV1, + VerifiedSealedLexicalSourceReceiptV1, VerifiedSealedLexicalSymbolDisplayV1, }; use tracedecay_code_index::projection::{ ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, @@ -47,8 +49,10 @@ use tracedecay_query::retrieval::exact::{ }; use tracedecay_query::retrieval::lexical::{ CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, - CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeLexicalArtifactBuilderV1, - CodeLexicalArtifactErrorV1, CodeLexicalArtifactFinalizationStepV1, CodeLexicalArtifactReaderV1, + CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1, + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeLexicalArtifactBatchLimitV1, + CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, + CodeLexicalArtifactFinalizationStepV1, CodeLexicalArtifactReaderV1, CodeLexicalProjectionAdapterV1, CodeLexicalProjectionBuildStepV1, CodeLexicalProjectionBuildV1, CodeLexicalProjectionMetadataV1, LexicalFieldFilterV1, LexicalFieldV1, LexicalLane, LexicalLaneRequest, LexicalLaneRetriever, MAX_FUZZY_TERM_EXPANSIONS_V1, @@ -172,6 +176,35 @@ struct CancelAtObservation { observations: AtomicUsize, } +struct CancelAtObservationWithJournalProbe { + cancellation_observation: usize, + observations: AtomicUsize, + journal_path: PathBuf, + journal_seen: AtomicBool, +} + +struct CancelOnBackgroundObservation { + caller: std::thread::ThreadId, +} + +impl CancelOnBackgroundObservation { + fn new() -> Self { + Self { + caller: std::thread::current().id(), + } + } +} + +impl CodeIndexExecutionControlV1 for CancelOnBackgroundObservation { + fn is_cancelled(&self) -> bool { + std::thread::current().id() != self.caller + } + + fn is_deadline_exceeded(&self) -> bool { + false + } +} + impl CancelAtObservation { fn new(cancellation_observation: usize) -> Self { Self { @@ -181,6 +214,23 @@ impl CancelAtObservation { } } +impl CancelAtObservationWithJournalProbe { + fn new(artifact_path: &Path, cancellation_observation: usize) -> Self { + let mut journal_path = artifact_path.as_os_str().to_owned(); + journal_path.push("-journal"); + Self { + cancellation_observation, + observations: AtomicUsize::new(0), + journal_path: PathBuf::from(journal_path), + journal_seen: AtomicBool::new(false), + } + } + + fn journal_seen(&self) -> bool { + self.journal_seen.load(Ordering::SeqCst) + } +} + impl CodeIndexExecutionControlV1 for CancelAtObservation { fn is_cancelled(&self) -> bool { let observations = self @@ -195,6 +245,25 @@ impl CodeIndexExecutionControlV1 for CancelAtObservation { } } +impl CodeIndexExecutionControlV1 for CancelAtObservationWithJournalProbe { + fn is_cancelled(&self) -> bool { + let observations = self + .observations + .fetch_add(1, Ordering::SeqCst) + .saturating_add(1); + if observations < self.cancellation_observation { + return false; + } + self.journal_seen + .store(self.journal_path.exists(), Ordering::SeqCst); + true + } + + fn is_deadline_exceeded(&self) -> bool { + false + } +} + #[derive(Default)] struct CancelAfterAcceptedPage { page_accepted: std::sync::atomic::AtomicBool, @@ -320,12 +389,10 @@ fn real_lexical_source_fixture() -> RealLexicalSourceFixture { /// scores tie across files without content-identical chunks. fn real_lexical_source_fixture_with_files(file_count: usize) -> RealLexicalSourceFixture { assert!(file_count >= 1, "fixture needs at least one file"); - let repository = id::("repository.artifact"); - let sanitizer_revision = id::("sanitizer.v1"); let identity_source = b"import type { Widget } from \"widget-kit\";\nexport function render(value: Widget) { return value; }\n"; - let sources: Vec<(SanitizedCodeFileV1, Vec)> = (0..file_count) + let sources = (0..file_count) .map(|ordinal| { - let (file_id, logical_path, source) = if ordinal == 0 { + if ordinal == 0 { ( "file.artifact".to_owned(), "src/artifact.ts".to_owned(), @@ -342,7 +409,21 @@ fn real_lexical_source_fixture_with_files(file_count: usize) -> RealLexicalSourc ) .into_bytes(), ) - }; + } + }) + .collect(); + real_lexical_source_fixture_from_sources(sources) +} + +fn real_lexical_source_fixture_from_sources( + source_inputs: Vec<(String, String, Vec)>, +) -> RealLexicalSourceFixture { + assert!(!source_inputs.is_empty(), "fixture needs at least one file"); + let repository = id::("repository.artifact"); + let sanitizer_revision = id::("sanitizer.v1"); + let sources = source_inputs + .into_iter() + .map(|(file_id, logical_path, source)| { let file = SanitizedCodeFileV1 { file_occurrence_id: id::(&file_id), logical_path, @@ -352,7 +433,11 @@ fn real_lexical_source_fixture_with_files(file_count: usize) -> RealLexicalSourc }; (file, source) }) - .collect(); + .collect::>(); + let identity_source = sources + .first() + .map(|(_, source)| source.as_slice()) + .expect("non-empty fixture sources"); let snapshot = SanitizedCodeSnapshotV1 { repository: repository.clone(), worktree: None, @@ -370,7 +455,7 @@ fn real_lexical_source_fixture_with_files(file_count: usize) -> RealLexicalSourc .iter() .map(|(file, source)| CodeIndexCapturedFileV1 { file_occurrence_id: file.file_occurrence_id.clone(), - sanitized_bytes: source.clone(), + sanitized_bytes: Arc::from(source.clone()), sensitivity_level: SensitivityLevelV1::Public, }) .collect(), @@ -486,6 +571,127 @@ fn real_verified_pages() -> ( real_verified_pages_with_maximum_page_chunks(128) } +fn page_batch_identities(pages: &[VerifiedSealedLexicalPageV1]) -> Vec<(u64, String, Vec)> { + pages + .iter() + .map(|page| { + ( + page.page_ordinal(), + page.page_digest().as_str().to_owned(), + page.next_cursor() + .persisted_bytes() + .expect("persist page cursor"), + ) + }) + .collect() +} + +#[test] +fn sealed_source_rejected_batch_retries_byte_identical_pages_and_cursor() { + let fixture = real_lexical_source_fixture(); + let control = ArtifactControl { cancelled: false }; + let mut source = fixture.open_source(1); + let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new(2, 64 * 1024 * 1024) + .expect("two-page batch bounds"); + let cursor_before = source.cursor().persisted_bytes().expect("initial cursor"); + let mut rejected_identities = None; + let rejected = source + .next_page_batch_if(&control, bounds, |pages| { + rejected_identities = Some(page_batch_identities(pages)); + Err("reject staged batch") + }) + .expect("stage rejected batch"); + assert!(matches!(rejected, Err("reject staged batch"))); + assert_eq!( + source.cursor().persisted_bytes().expect("rejected cursor"), + cursor_before, + "callback rejection must retain the byte-exact source cursor" + ); + + let retried = source + .next_page_batch_if(&control, bounds, |pages| { + Ok::<_, &'static str>(NonZeroUsize::new(pages.len()).expect("non-empty source batch")) + }) + .expect("retry staged batch") + .expect("accept retried batch"); + let VerifiedSealedLexicalPageBatchReadV1::Pages(retried_pages) = retried else { + panic!("fixture must emit a retried page batch"); + }; + assert_eq!( + page_batch_identities(&retried_pages), + rejected_identities.expect("rejected page identities"), + "retry must reproduce the exact ordered pages" + ); + assert_eq!( + source.cursor().persisted_bytes().expect("accepted cursor"), + retried_pages + .last() + .expect("retried pages") + .next_cursor() + .persisted_bytes() + .expect("final accepted cursor"), + "acceptance advances exactly to the final page" + ); +} + +#[test] +fn sealed_source_batch_bounds_and_completion_never_advance_empty_work() { + assert!(VerifiedSealedLexicalPageBatchBoundsV1::new(0, 1).is_err()); + assert!(VerifiedSealedLexicalPageBatchBoundsV1::new(1, 0).is_err()); + + let fixture = real_lexical_source_fixture(); + let control = ArtifactControl { cancelled: false }; + let (pages, _) = drain_verified_pages(&fixture, 1); + let first_page_bound = + std::mem::size_of::() + pages[0].retained_owned_bytes() - 1; + let too_small = VerifiedSealedLexicalPageBatchBoundsV1::new(1, first_page_bound) + .expect("sub-page batch bound"); + let mut source = fixture.open_source(1); + let cursor_before = source.cursor().persisted_bytes().expect("initial cursor"); + let callbacks = AtomicUsize::new(0); + assert!( + source + .next_page_batch_if(&control, too_small, |_| { + callbacks.fetch_add(1, Ordering::SeqCst); + Ok::<_, ()>(NonZeroUsize::MIN) + }) + .is_err(), + "a first page above the retained-byte bound is a typed source error" + ); + assert_eq!(callbacks.load(Ordering::SeqCst), 0); + assert_eq!( + source.cursor().persisted_bytes().expect("refused cursor"), + cursor_before + ); + + let bounds = + VerifiedSealedLexicalPageBatchBoundsV1::new(2, 64 * 1024 * 1024).expect("drain bounds"); + loop { + let before = callbacks.load(Ordering::SeqCst); + let read = source + .next_page_batch_if(&control, bounds, |pages| { + callbacks.fetch_add(1, Ordering::SeqCst); + Ok::<_, ()>(NonZeroUsize::new(pages.len()).expect("non-empty source batch")) + }) + .expect("drain source") + .expect("accept source batch"); + match read { + VerifiedSealedLexicalPageBatchReadV1::Pages(pages) => { + assert!(!pages.is_empty(), "page batches are never empty"); + assert_eq!(callbacks.load(Ordering::SeqCst), before + 1); + } + VerifiedSealedLexicalPageBatchReadV1::Complete(_) => { + assert_eq!( + callbacks.load(Ordering::SeqCst), + before, + "completion must bypass the page callback" + ); + break; + } + } + } +} + fn finish_staged_artifact( builder: &mut CodeLexicalArtifactBuilderV1, source_receipt: &VerifiedSealedLexicalSourceReceiptV1, @@ -502,6 +708,18 @@ fn finish_staged_artifact( } } +fn stored_base_section_receipts(path: &Path) -> Vec> { + let connection = rusqlite::Connection::open(path).expect("open artifact receipt inspection"); + let mut statement = connection + .prepare("SELECT base_sections_receipt FROM source_pages ORDER BY page_ordinal") + .expect("prepare ordered base-section receipt query"); + statement + .query_map([], |row| row.get(0)) + .expect("query ordered base-section receipts") + .collect::, _>>() + .expect("collect ordered base-section receipts") +} + /// Fixture-only source driver retained for legacy regression setup. Production /// finalization receives the source receipt and never owns a source reader. trait TestArtifactSourceStaging { @@ -518,15 +736,15 @@ impl TestArtifactSourceStaging for CodeLexicalArtifactBuilderV1 { source: &mut VerifiedSealedLexicalPageSourceV1, control: &dyn CodeIndexExecutionControlV1, ) -> Result { + let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new(16, 32 * 1024 * 1024) + .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; let receipt = loop { - let staged_pages = self.progress()?.next_page_ordinal; let admitted = source - .next_page_if(control, |page| { - if page.page_ordinal() < staged_pages { - Ok(()) - } else { - self.append_page(page, control).map(|_| ()) - } + .next_page_batch_if(control, bounds, |pages| { + let prepared = self.prepare_admissible_page_prefix(pages, control)?; + let accepted = prepared.accepted_prefix(); + self.append_prepared_pages(prepared.prepared_pages(), control)?; + Ok(accepted) }) .map_err(|error| match error { CodeIndexProductionErrorV1::Interrupted(interruption) => { @@ -535,8 +753,8 @@ impl TestArtifactSourceStaging for CodeLexicalArtifactBuilderV1 { error => CodeLexicalArtifactErrorV1::Corrupt(error.to_string()), })?; match admitted? { - VerifiedSealedLexicalPageReadV1::Page(_) => {} - VerifiedSealedLexicalPageReadV1::Complete(receipt) => break receipt, + VerifiedSealedLexicalPageBatchReadV1::Pages(_) => {} + VerifiedSealedLexicalPageBatchReadV1::Complete(receipt) => break receipt, } }; Ok(finish_staged_artifact(self, &receipt, control)) @@ -955,6 +1173,20 @@ fn disk_artifact_resume_reopen_and_lexical_results_match_one_shot_projection() { .expect("artifact row lookup") .expect("artifact occurrence"); assert_eq!(occurrence.logical_path, "src/artifact.ts"); + let symbol_chunk = chunks + .iter() + .find(|chunk| chunk.chunk().anchor.symbol_occurrence_id.is_some()) + .expect("parser-backed symbol chunk"); + let symbol_occurrence = reader + .occurrence_by_chunk(&symbol_chunk.chunk().id) + .expect("artifact symbol row lookup") + .expect("artifact symbol occurrence"); + assert_eq!(symbol_occurrence.simple_name.as_deref(), Some("render")); + assert_eq!( + symbol_occurrence.qualified_name.as_deref(), + Some("src/artifact.ts::render") + ); + assert_eq!(symbol_occurrence.kind.as_deref(), Some("function")); let import_witness = reader .import_membership(&import_evidence) .expect("import membership") @@ -983,6 +1215,102 @@ fn disk_artifact_resume_reopen_and_lexical_results_match_one_shot_projection() { assert_eq!(artifact, expected); } +#[test] +fn disk_artifact_batch_stores_one_ngram_bitmap_shard_per_distinct_key() { + let (fixture, pages, source_receipt) = real_verified_pages(); + let metadata = fixture.metadata.clone(); + let generation = metadata.generation.clone(); + let chunks = pages + .iter() + .flat_map(|page| page.chunks().iter().cloned()) + .collect::>(); + let one_shot = CodeLexicalProjectionAdapterV1::new_admitted(metadata.clone(), chunks) + .expect("one-shot lexical projection"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("ngram-bitmap-shards.sqlite"); + let control = ArtifactControl { cancelled: false }; + let mut builder = + CodeLexicalArtifactBuilderV1::create(&artifact_path, metadata).expect("create artifact"); + builder + .append_pages(&pages, &control) + .expect("commit one durable source batch"); + + let connection = rusqlite::Connection::open(&artifact_path).expect("inspect ngram shards"); + let (stored_rows, distinct_keys): (i64, i64) = connection + .query_row( + "SELECT COUNT(*), COUNT(DISTINCT printf('%d:%d', kind, ngram)) FROM ngram_postings", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("count durable ngram keys"); + assert!(stored_rows > 0, "the fixture must produce ngram candidates"); + assert_eq!( + stored_rows, distinct_keys, + "one atomic source batch must store one bitmap shard per distinct (kind, ngram), not one row per matching document" + ); + drop(connection); + + let verified = finish_staged_artifact(&mut builder, &source_receipt, &control); + let reader = CodeLexicalArtifactReaderV1::open_with_control( + &artifact_path, + &verified, + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, + &control, + ) + .expect("open finalized bitmap artifact"); + let mut request = lexical_request( + "rendre return value", + &["rendre"], + &[], + &["return value"], + 2, + 8, + ); + request.generation = generation; + assert_eq!( + LexicalLane::new(reader) + .retrieve_lexical(&request) + .expect("bitmap artifact lexical query"), + LexicalLane::new(one_shot) + .retrieve_lexical(&request) + .expect("one-shot lexical query") + ); +} + +#[test] +fn disk_artifact_base_receipts_are_independent_of_commit_batch_width() { + let (fixture, pages, source_receipt) = real_verified_pages_with_maximum_page_chunks(1); + assert!(pages.len() > 1, "fixture must span multiple source pages"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let one_page_path = directory.path().join("one-page-receipts.sqlite"); + let batched_path = directory.path().join("batched-receipts.sqlite"); + let control = ArtifactControl { cancelled: false }; + + let mut one_page = + CodeLexicalArtifactBuilderV1::create(&one_page_path, fixture.metadata.clone()) + .expect("create one-page artifact"); + for page in &pages { + one_page + .append_page(page, &control) + .expect("commit one source page"); + } + + let mut batched = CodeLexicalArtifactBuilderV1::create(&batched_path, fixture.metadata) + .expect("create batched artifact"); + batched + .append_pages(&pages, &control) + .expect("commit one multi-page batch"); + + let one_page_receipts = stored_base_section_receipts(&one_page_path); + let batched_receipts = stored_base_section_receipts(&batched_path); + assert_eq!(one_page_receipts.len(), pages.len()); + assert_eq!(one_page_receipts, batched_receipts); + + let one_page_verified = finish_staged_artifact(&mut one_page, &source_receipt, &control); + let batched_verified = finish_staged_artifact(&mut batched, &source_receipt, &control); + assert_eq!(one_page_verified, batched_verified); +} + #[test] fn content_addressed_reader_rejects_atomic_same_size_replacement() { let (fixture, pages, source_receipt) = real_verified_pages(); @@ -1068,34 +1396,879 @@ fn reader_rejects_revision_four_artifact_before_indexed_queries() { } #[test] -fn reader_rejects_current_artifact_missing_required_term_statistics_index() { - let (fixture, pages, source_receipt) = real_verified_pages(); +fn reader_rejects_current_artifact_missing_required_term_statistics_index() { + let (fixture, pages, source_receipt) = real_verified_pages(); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory + .path() + .join("missing-term-statistics-index.sqlite"); + let control = ArtifactControl { cancelled: false }; + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) + .expect("create artifact"); + for page in &pages { + builder.append_page(page, &control).expect("append page"); + } + let verified = finish_staged_artifact(&mut builder, &source_receipt, &control); + let connection = rusqlite::Connection::open(&artifact_path).expect("open artifact mutation"); + connection + .execute_batch("DROP INDEX term_stats_by_term;") + .expect("remove required term-statistics index"); + drop(connection); + + assert!(matches!( + CodeLexicalArtifactReaderV1::open_with_control( + &artifact_path, + &verified, + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, + &control, + ), + Err(CodeLexicalArtifactErrorV1::Incompatible(_)) + )); +} + +#[test] +fn disk_artifact_defers_statistics_and_serving_indexes_until_freeze() { + let (fixture, pages, source_receipt) = real_verified_pages(); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("deferred-serving-state.sqlite"); + let control = ArtifactControl { cancelled: false }; + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) + .expect("create artifact"); + for page in &pages { + builder.append_page(page, &control).expect("append page"); + } + + let connection = rusqlite::Connection::open(&artifact_path).expect("inspect staging artifact"); + let staging_indexes: Vec = connection + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex_%' ORDER BY name", + ) + .expect("prepare index inventory") + .query_map([], |row| row.get(0)) + .expect("query index inventory") + .collect::>() + .expect("read index inventory"); + assert_eq!(staging_indexes, Vec::::new()); + for table in ["field_stats", "term_stats", "vocabulary"] { + let rows: i64 = connection + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .expect("count deferred statistic rows"); + assert_eq!(rows, 0, "{table} must be derived after the base freeze"); + } + let authority_rows: i64 = connection + .query_row( + "SELECT (SELECT COUNT(*) FROM source_pages) + \ + (SELECT COUNT(*) FROM document_integrity) + \ + (SELECT COUNT(*) FROM import_integrity) + \ + (SELECT COUNT(*) FROM import_evidence)", + [], + |row| row.get(0), + ) + .expect("count authenticated authority rows"); + let epoch: i64 = connection + .query_row( + "SELECT epoch FROM content_epoch WHERE singleton = 1", + [], + |row| row.get(0), + ) + .expect("read authenticated authority epoch"); + assert_eq!(epoch, authority_rows); + drop(connection); + + assert!(matches!( + builder + .advance_finalization(&source_receipt, 1, &control) + .expect("persist base freeze"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); + let connection = rusqlite::Connection::open(&artifact_path).expect("inspect frozen artifact"); + assert!( + connection + .execute( + "UPDATE rows SET row = row WHERE document_id = (SELECT MIN(document_id) FROM rows)", + [], + ) + .is_err(), + "the persisted freeze must deny base-row mutation" + ); + drop(connection); + + let verified = finish_staged_artifact(&mut builder, &source_receipt, &control); + assert_eq!(verified.total_chunks(), source_receipt.total_chunks()); + let connection = rusqlite::Connection::open(&artifact_path).expect("inspect sealed artifact"); + let serving_indexes: Vec = connection + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex_%' ORDER BY name", + ) + .expect("prepare final index inventory") + .query_map([], |row| row.get(0)) + .expect("query final index inventory") + .collect::>() + .expect("read final index inventory"); + assert_eq!( + serving_indexes, + [ + "exact_postings_by_document", + "ngram_postings_by_ngram", + "rows_by_chunk", + "term_postings_by_document", + "term_postings_by_document_term", + "term_postings_by_term", + "term_stats_by_term", + ] + ); + let incorrect_field_stats: i64 = connection + .query_row( + "SELECT COUNT(*) FROM field_stats AS actual LEFT JOIN (SELECT field, SUM(frequency) AS total_length FROM term_postings GROUP BY field) AS expected USING(field) WHERE actual.total_length != expected.total_length", + [], + |row| row.get(0), + ) + .expect("compare field statistics"); + let incorrect_term_stats: i64 = connection + .query_row( + "SELECT COUNT(*) FROM term_stats AS actual LEFT JOIN (SELECT field, term, COUNT(*) AS document_frequency FROM term_postings GROUP BY field, term) AS expected USING(field, term) WHERE actual.document_frequency != expected.document_frequency", + [], + |row| row.get(0), + ) + .expect("compare term statistics"); + assert_eq!(incorrect_field_stats, 0); + assert_eq!(incorrect_term_stats, 0); +} + +#[test] +fn disk_artifact_production_wake_commits_one_restartable_setwise_step() { + let fixture = real_lexical_source_fixture_with_files(64); + let (pages, source_receipt) = drain_verified_pages(&fixture, 128); + let metadata = fixture.metadata.clone(); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("restartable-setwise-steps.sqlite"); + let control = ArtifactControl { cancelled: false }; + let mut builder = + CodeLexicalArtifactBuilderV1::create(&artifact_path, metadata.clone()).expect("create"); + for page in &pages { + builder.append_page(page, &control).expect("append page"); + } + + assert!(matches!( + builder + .advance_finalization(&source_receipt, 4_096, &control) + .expect("persist base freeze"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); + assert_eq!( + persisted_finalization_position(&artifact_path), + ("statistics".to_owned(), 0) + ); + assert!(matches!( + builder + .advance_finalization(&source_receipt, 4_096, &control) + .expect("derive only field statistics"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); + assert_eq!( + persisted_finalization_position(&artifact_path), + ("statistics".to_owned(), 1), + "a production-sized wake commits exactly one corpus-wide step" + ); + drop(builder); + + let mut resumed = CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &artifact_path, + metadata.clone(), + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("restart after committed field statistics"); + let cancellation = CancelOnBackgroundObservation::new(); + assert!(matches!( + resumed.advance_finalization(&source_receipt, 4_096, &cancellation), + Err(CodeLexicalArtifactErrorV1::Interrupted( + CodeIndexInterruptionV1::Cancelled + )) + )); + assert_eq!( + persisted_finalization_position(&artifact_path), + ("statistics".to_owned(), 1), + "cancellation inside the next SQLite statement must not advance its durable state" + ); + let connection = rusqlite::Connection::open(&artifact_path).expect("inspect cancelled step"); + let field_rows: i64 = connection + .query_row("SELECT COUNT(*) FROM field_stats", [], |row| row.get(0)) + .expect("count committed field statistics"); + let term_rows: i64 = connection + .query_row("SELECT COUNT(*) FROM term_stats", [], |row| row.get(0)) + .expect("count rolled-back term statistics"); + assert!( + field_rows > 0, + "the prior committed step survives cancellation" + ); + assert_eq!(term_rows, 0, "the interrupted step rolls back atomically"); + drop(connection); + drop(resumed); + + let mut resumed = CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &artifact_path, + metadata.clone(), + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("restart after cancelled term statistics"); + resumed + .advance_finalization(&source_receipt, 4_096, &control) + .expect("retry only term statistics"); + assert_eq!( + persisted_finalization_position(&artifact_path), + ("statistics".to_owned(), 2), + "retry resumes at the interrupted step instead of replaying the frozen prior step" + ); + drop(resumed); + + let mut expected_indexes = 0i64; + for expected_position in 0..=7u64 { + let mut resumed = + CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &artifact_path, + metadata.clone(), + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("restart between corpus-wide steps"); + resumed + .advance_finalization(&source_receipt, 4_096, &control) + .expect("advance one corpus-wide step"); + drop(resumed); + + if expected_position == 0 { + assert_eq!( + persisted_finalization_position(&artifact_path), + ("indexes".to_owned(), 0), + "the vocabulary step alone transitions to index construction" + ); + } else { + expected_indexes += 1; + let expected_state = if expected_position == 7 { + ("digest".to_owned(), 0) + } else { + ("indexes".to_owned(), expected_position) + }; + assert_eq!( + persisted_finalization_position(&artifact_path), + expected_state + ); + let connection = + rusqlite::Connection::open(&artifact_path).expect("inspect serving indexes"); + let indexes: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex_%'", + [], + |row| row.get(0), + ) + .expect("count committed serving indexes"); + assert_eq!( + indexes, expected_indexes, + "each restarted production wake commits exactly one serving index" + ); + } + } +} + +fn persisted_finalization_position(path: &Path) -> (String, u64) { + let connection = rusqlite::Connection::open(path).expect("open finalization state"); + let state: Vec = connection + .query_row( + "SELECT state FROM finalization_state WHERE singleton = 1", + [], + |row| row.get(0), + ) + .expect("read finalization state"); + let state: serde_json::Value = + serde_json::from_slice(&state).expect("decode finalization state"); + let phase = state["phase"] + .as_str() + .expect("finalization phase") + .to_owned(); + let ordinal = state["section_ordinal"] + .as_u64() + .expect("finalization section ordinal"); + (phase, ordinal) +} + +#[test] +fn disk_artifact_admission_selects_the_exact_largest_contiguous_prefix() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + assert!( + pages.len() >= 2, + "fixture must expose a real prefix boundary" + ); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let probe_path = directory.path().join("prefix-probe.sqlite"); + let probe = CodeLexicalArtifactBuilderV1::create(&probe_path, fixture.metadata.clone()) + .expect("create admission probe"); + let first_page_charge = probe + .page_batch_ledger_charge_bytes(&pages[..1]) + .expect("measure first page charge"); + let exact_budget = probe + .fixed_ledger_charge_bytes() + .checked_add(first_page_charge) + .expect("exact first-page budget"); + drop(probe); + + let artifact_path = directory.path().join("prefix-bound.sqlite"); + let builder = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &artifact_path, + fixture.metadata, + exact_budget, + ) + .expect("create exactly bounded builder"); + assert_eq!( + builder + .largest_admissible_page_prefix(&pages) + .expect("select admissible prefix"), + 1, + "the selector must accept the equality boundary and stop before the first over-budget page" + ); +} + +#[test] +fn disk_artifact_admission_keeps_real_pages_wide_until_the_actual_limit() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + assert!( + pages.len() >= 3, + "parser-backed fixture must expose a three-page boundary" + ); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let probe = CodeLexicalArtifactBuilderV1::create( + directory.path().join("wide-prefix-probe.sqlite"), + fixture.metadata.clone(), + ) + .expect("create admission probe"); + let two_page_charge = probe + .page_batch_ledger_charge_bytes(&pages[..2]) + .expect("measure two-page charge"); + let three_page_charge = probe + .page_batch_ledger_charge_bytes(&pages[..3]) + .expect("measure three-page charge"); + let exact_budget = probe + .fixed_ledger_charge_bytes() + .checked_add(two_page_charge) + .expect("exact two-page budget"); + assert!( + probe.fixed_ledger_charge_bytes() + three_page_charge > exact_budget, + "the third real page must be the actual memory authority boundary" + ); + drop(probe); + + let artifact_path = directory.path().join("wide-prefix.sqlite"); + let mut builder = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &artifact_path, + fixture.metadata, + exact_budget, + ) + .expect("create exactly bounded builder"); + let selected = builder + .largest_admissible_page_prefix(&pages) + .expect("select real parser-backed prefix"); + assert_eq!( + selected, 2, + "all-limit preflight must preserve a two-page batch and stop at its real third-page bound" + ); + let progress = builder + .append_pages(&pages[..selected], &ArtifactControl { cancelled: false }) + .expect("the selected multi-page prefix must pass exact post-preparation admission"); + assert_eq!(progress.next_page_ordinal, 2); +} + +#[test] +fn disk_artifact_term_insert_execution_is_monotone_by_primary_key() { + let (fixture, pages, _) = real_verified_pages(); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("term-insert-order.sqlite"); + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) + .expect("create artifact"); + let trace = rusqlite::Connection::open(&artifact_path).expect("open term insert observer"); + trace + .execute_batch( + "CREATE TABLE term_insert_trace ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + field TEXT NOT NULL, + term TEXT NOT NULL, + document_id INTEGER NOT NULL + ); + CREATE TRIGGER trace_term_insert AFTER INSERT ON term_postings BEGIN + INSERT INTO term_insert_trace(field, term, document_id) + VALUES (NEW.field, NEW.term, NEW.document_id); + END;", + ) + .expect("install term insert observer"); + drop(trace); + + builder + .append_pages(&pages, &ArtifactControl { cancelled: false }) + .expect("append observed term postings"); + let trace = rusqlite::Connection::open(&artifact_path).expect("read term insert observer"); + let keys = trace + .prepare("SELECT field, term, document_id FROM term_insert_trace ORDER BY sequence") + .expect("prepare term insert trace") + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .expect("query term insert trace") + .collect::, _>>() + .expect("read term insert trace"); + assert!(keys.len() > 1, "fixture must emit multiple term postings"); + let resets = keys.windows(2).filter(|pair| pair[1] < pair[0]).count(); + assert_eq!( + resets, 0, + "term INSERT execution must follow the WITHOUT ROWID primary key" + ); +} + +#[test] +fn disk_artifact_term_insert_plan_obeys_exact_memory_boundary_before_mutation() { + const TERM_INSERT_PLAN_BYTES_PER_REF: usize = 3 * std::mem::size_of::(); + const TERM_INSERT_SORT_RUN_ROWS: usize = 4_096; + + let (fixture, pages, _) = real_verified_pages(); + let pages = &pages[..1]; + let metadata = fixture.metadata; + let directory = tempfile::tempdir().expect("artifact tempdir"); + let probe_path = directory.path().join("term-plan-probe.sqlite"); + let mut probe = CodeLexicalArtifactBuilderV1::create(&probe_path, metadata.clone()) + .expect("create term plan probe"); + let control = ArtifactControl { cancelled: false }; + let prepared = probe + .prepare_pages(pages, &control) + .expect("prepare term plan fixture"); + let prepared_ledger = prepared[0] + .ledger_charge_bytes() + .expect("prepared page ledger charge"); + let fixed_ledger = probe.fixed_ledger_charge_bytes(); + probe + .append_prepared_pages(&prepared, &control) + .expect("append term plan probe"); + let term_rows = rusqlite::Connection::open(&probe_path) + .expect("open term plan probe") + .query_row("SELECT COUNT(*) FROM term_postings", [], |row| { + row.get::<_, i64>(0) + }) + .expect("count prepared term rows"); + let term_rows = usize::try_from(term_rows).expect("term row count"); + assert!(term_rows > 0, "fixture must emit term postings"); + let entry_ledger = term_rows + .checked_mul(TERM_INSERT_PLAN_BYTES_PER_REF) + .expect("term plan ledger charge"); + let merge_heap_ledger = term_rows + .div_ceil(TERM_INSERT_SORT_RUN_ROWS) + .checked_mul(std::mem::size_of::<(i64, usize, usize, usize)>()) + .expect("term merge heap ledger charge"); + let plan_ledger = entry_ledger + .checked_add(merge_heap_ledger) + .expect("complete term plan ledger charge"); + let exact_budget = fixed_ledger + .checked_add(prepared_ledger) + .and_then(|bytes| bytes.checked_add(plan_ledger)) + .expect("exact term plan budget"); + drop(probe); + + let refused_path = directory.path().join("term-plan-refused.sqlite"); + let mut refused = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &refused_path, + metadata.clone(), + exact_budget - 1, + ) + .expect("create one-byte-under term plan builder"); + assert_eq!(refused.fixed_ledger_charge_bytes(), fixed_ledger); + assert!(matches!( + refused.append_prepared_pages(&prepared, &control), + Err(CodeLexicalArtifactErrorV1::BatchTooLarge { + limit: CodeLexicalArtifactBatchLimitV1::Memory, + required, + maximum, + }) if required == exact_budget && maximum == exact_budget - 1 + )); + assert_eq!( + refused + .progress() + .expect("progress after term plan refusal") + .next_page_ordinal, + 0 + ); + assert_eq!(staged_row_cardinality(&refused_path), (0, 0)); + let refused_term_rows: i64 = rusqlite::Connection::open(&refused_path) + .expect("open refused term plan artifact") + .query_row("SELECT COUNT(*) FROM term_postings", [], |row| row.get(0)) + .expect("count refused term rows"); + assert_eq!(refused_term_rows, 0); + drop(refused); + + let interrupted_path = directory.path().join("term-plan-interrupted.sqlite"); + let mut interrupted = CodeLexicalArtifactBuilderV1::create(&interrupted_path, metadata.clone()) + .expect("create interrupted term plan builder"); + let documents = usize::try_from(prepared[0].chunk_count()).expect("prepared document count"); + // Append entry + plan entry + both page/document passes + checkpoints + // before and after the single bounded run + the post-run checkpoint. + let post_sort_observation = documents + .checked_mul(2) + .and_then(|observations| observations.checked_add(7)) + .expect("post-sort observation"); + let cancellation = CancelAtObservation::new(post_sort_observation); + assert!(matches!( + interrupted.append_prepared_pages(&prepared, &cancellation), + Err(CodeLexicalArtifactErrorV1::Interrupted(_)) + )); + assert_eq!( + interrupted + .progress() + .expect("progress after term plan interruption") + .next_page_ordinal, + 0, + "post-sort cancellation must precede transaction entry" + ); + assert_eq!(staged_row_cardinality(&interrupted_path), (0, 0)); + drop(interrupted); + let mut resumed = CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &interrupted_path, + metadata.clone(), + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("resume after term plan interruption"); + assert_eq!( + resumed + .append_prepared_pages(&prepared, &control) + .expect("resume exact prepared batch") + .next_page_ordinal, + 1 + ); + drop(resumed); + + let exact_path = directory.path().join("term-plan-exact.sqlite"); + let mut exact = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &exact_path, + metadata, + exact_budget, + ) + .expect("create exact term plan builder"); + let progress = exact + .append_prepared_pages(&prepared, &control) + .expect("accept exact term plan boundary"); + assert_eq!(progress.next_page_ordinal, 1); +} + +#[test] +fn disk_artifact_term_run_sort_observes_cancellation_before_transaction_entry() { + const TERM_SORT_RUN_ROWS: usize = 4_096; + + let mut source = String::with_capacity(192 * 1024); + for ordinal in 0..768 { + source.push_str(&format!( + "export function ordered_symbol_{ordinal:04}(input_value: string) {{ const local_value_{ordinal:04} = input_value + 'term_{ordinal:04}'; return local_value_{ordinal:04}; }}\n" + )); + } + let fixture = real_lexical_source_fixture_from_sources(vec![( + "file.artifact.term-runs".to_owned(), + "src/term-runs.ts".to_owned(), + source.into_bytes(), + )]); + let (pages, _) = drain_verified_pages(&fixture, 128); + assert!( + pages.iter().all(|page| page.imports().is_empty()), + "term-run fixture must reach document writes without import checkpoints" + ); + let metadata = fixture.metadata; + let directory = tempfile::tempdir().expect("artifact tempdir"); + let probe_path = directory.path().join("term-run-probe.sqlite"); + let mut probe = CodeLexicalArtifactBuilderV1::create(&probe_path, metadata.clone()) + .expect("create term-run probe"); + let control = ArtifactControl { cancelled: false }; + let prepared = probe + .prepare_pages(&pages, &control) + .expect("prepare multi-run term batch"); + probe + .append_prepared_pages(&prepared, &control) + .expect("append term-run probe"); + let term_rows = rusqlite::Connection::open(&probe_path) + .expect("open term-run probe") + .query_row("SELECT COUNT(*) FROM term_postings", [], |row| { + row.get::<_, i64>(0) + }) + .expect("count term-run rows"); + let term_rows = usize::try_from(term_rows).expect("term-run row count"); + assert!( + term_rows > TERM_SORT_RUN_ROWS, + "fixture must require at least two bounded sort runs: {term_rows}" + ); + drop(probe); + + let interrupted_path = directory.path().join("term-run-interrupted.sqlite"); + let mut interrupted = CodeLexicalArtifactBuilderV1::create(&interrupted_path, metadata.clone()) + .expect("create interrupted term-run builder"); + let page_count = prepared.len(); + let document_count = prepared.iter().try_fold(0usize, |documents, page| { + usize::try_from(page.chunk_count()) + .ok() + .and_then(|page_documents| documents.checked_add(page_documents)) + }); + let document_count = document_count.expect("prepared document count"); + // Entry checkpoints plus both page/document passes consume + // 2 + 2*pages + 2*documents observations. The third later observation is + // the checkpoint before the second bounded sort run. With one monolithic + // sort it instead occurs after the first document row has opened SQLite's + // DELETE-mode rollback journal, making this regression non-vacuous. + let cancellation_observation = page_count + .checked_mul(2) + .and_then(|observations| { + document_count + .checked_mul(2) + .and_then(|documents| observations.checked_add(documents)) + }) + .and_then(|observations| observations.checked_add(5)) + .expect("second term sort run observation"); + let cancellation = + CancelAtObservationWithJournalProbe::new(&interrupted_path, cancellation_observation); + assert!(matches!( + interrupted.append_prepared_pages(&prepared, &cancellation), + Err(CodeLexicalArtifactErrorV1::Interrupted(_)) + )); + assert!( + !cancellation.journal_seen(), + "sort-scale cancellation must be observed before SQLite opens its rollback journal" + ); + assert_eq!( + interrupted + .progress() + .expect("progress after run-sort interruption") + .next_page_ordinal, + 0 + ); + assert_eq!(staged_row_cardinality(&interrupted_path), (0, 0)); + drop(interrupted); + + let mut resumed = CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &interrupted_path, + metadata, + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("reopen after run-sort interruption"); + assert_eq!( + resumed + .append_prepared_pages(&prepared, &control) + .expect("retry interrupted term runs") + .next_page_ordinal, + u64::try_from(prepared.len()).expect("prepared page count") + ); +} + +#[test] +fn disk_artifact_widened_reservation_commits_high_ngram_window_atomically() { + const PRIOR_BUILD_BUDGET_BYTES: usize = 768 * 1024 * 1024; + const WIDENED_BUILD_BUDGET_BYTES: usize = 1536 * 1024 * 1024; + const SOURCE_WINDOW_BYTES: usize = 64 * 1024 * 1024; + const MAXIMUM_PREPARED_BATCH_ROWS: usize = 2_000_000; + const MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES: usize = 256 * 1024 * 1024; + + let sources = (0..32) + .map(|file_ordinal| { + let mut source = String::with_capacity(128 * 1024); + for symbol_ordinal in 0..128 { + let mut state = u64::try_from(file_ordinal * 128 + symbol_ordinal + 1) + .expect("fixture seed"); + let token = (0..240) + .map(|_| { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + let alphabet_ordinal = + usize::try_from((state >> 32) % 26).expect("alphabet ordinal"); + char::from(b'a' + u8::try_from(alphabet_ordinal).expect("ASCII letter")) + }) + .collect::(); + source.push_str(&format!( + "export function symbol_{file_ordinal:02}_{symbol_ordinal:03}(value: string) {{ return value + '{token}'; }}\n" + )); + } + ( + format!("file.artifact.high-ngram.{file_ordinal:02}"), + format!("src/high-ngram-{file_ordinal:02}.ts"), + source.into_bytes(), + ) + }) + .collect(); + let fixture = real_lexical_source_fixture_from_sources(sources); + let (pages, _) = drain_verified_pages(&fixture, 128); + assert!( + pages.len() >= 32, + "the parser-backed high-ngram corpus must expose a full 32-page source window" + ); + let pages = &pages[..32]; + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("thirty-two-page-batch.sqlite"); + + let prior_path = directory.path().join("prior-reservation.sqlite"); + let mut prior = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &prior_path, + fixture.metadata.clone(), + PRIOR_BUILD_BUDGET_BYTES, + ) + .expect("create builder with the prior reservation"); + let batch_charge = prior + .fixed_ledger_charge_bytes() + .checked_add( + prior + .page_batch_ledger_charge_bytes(pages) + .expect("measure high-ngram window"), + ) + .expect("high-ngram window ledger charge"); + let staging_window_bytes = fixture.open_source(128).staging_window_bytes(); + let production_builder_budget = WIDENED_BUILD_BUDGET_BYTES + .checked_sub(staging_window_bytes) + .expect("production builder budget after source reservation"); + assert!( + batch_charge > PRIOR_BUILD_BUDGET_BYTES, + "the production-shaped window must reproduce the measured 768 MiB memory limit: {batch_charge}" + ); + assert!( + batch_charge <= production_builder_budget, + "the same bounded window must fit after the production source reservation: batch={batch_charge}, staging={staging_window_bytes}, builder={production_builder_budget}" + ); + assert!( + prior + .largest_admissible_page_prefix(pages) + .expect("select prior reservation prefix") + < pages.len(), + "the prior reservation must stop before the complete high-ngram window" + ); + assert!(matches!( + prior.append_pages(pages, &ArtifactControl { cancelled: false }), + Err(CodeLexicalArtifactErrorV1::BatchTooLarge { + limit: CodeLexicalArtifactBatchLimitV1::Memory, + required, + maximum: PRIOR_BUILD_BUDGET_BYTES, + }) if required == batch_charge + )); + assert_eq!( + prior + .progress() + .expect("progress after typed reservation denial") + .next_page_ordinal, + 0, + "the memory denial must precede the atomic staging transaction" + ); + drop(prior); + + assert_eq!( + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, WIDENED_BUILD_BUDGET_BYTES, + "the canonical reservation must cover the measured production window" + ); + let mut builder = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &artifact_path, + fixture.metadata, + production_builder_budget, + ) + .expect("create builder after the production source reservation"); + assert_eq!( + builder + .largest_admissible_page_prefix(pages) + .expect("select widened reservation prefix"), + pages.len(), + "the widened memory authority must admit the complete window" + ); + let source_retained_bytes = pages + .iter() + .map(VerifiedSealedLexicalPageV1::retained_owned_bytes) + .sum::(); + assert!( + source_retained_bytes <= SOURCE_WINDOW_BYTES, + "the fixture must remain inside the 64 MiB source window: {source_retained_bytes}" + ); + assert!( + pages.iter().all(|page| { + page.retained_owned_bytes() <= CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 + }), + "every source page must remain inside its unchanged retained-byte bound" + ); + let control = ArtifactControl { cancelled: false }; + let prepared = builder + .prepare_pages(pages, &control) + .expect("prepare one full production source window"); + let estimated_rows = prepared + .iter() + .map(|page| page.estimated_write_rows()) + .sum::(); + let estimated_write_bytes = prepared + .iter() + .map(|page| page.estimated_write_bytes()) + .sum::(); + assert!( + estimated_rows <= MAXIMUM_PREPARED_BATCH_ROWS, + "the high-ngram window must remain inside the unchanged row bound: {estimated_rows}" + ); + assert!( + estimated_write_bytes <= MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES, + "the high-ngram window must remain inside the unchanged write bound: {estimated_write_bytes}" + ); + let progress = builder + .append_prepared_pages(&prepared, &control) + .expect("commit the complete real prefix atomically"); + assert_eq!(progress.next_page_ordinal, 32); + assert_eq!( + staged_row_cardinality(&artifact_path).0, + pages + .iter() + .map(VerifiedSealedLexicalPageV1::chunk_count) + .sum::(), + "one transaction must make every page row visible together" + ); +} + +#[test] +fn disk_artifact_repetitive_multi_chunk_page_makes_exact_prefix_progress() { + let mut source = String::with_capacity(700_000); + source.push_str("// "); + source.push_str(&"a".repeat(650_000)); + source.push_str( + "\nexport function first() { return 1; }\nexport function second() { return 2; }\n", + ); + let fixture = real_lexical_source_fixture_from_sources(vec![( + "file.artifact.repetitive".to_owned(), + "src/repetitive.ts".to_owned(), + source.into_bytes(), + )]); + let (pages, _) = drain_verified_pages(&fixture, 128); + let page = pages.first().expect("repetitive source page"); + assert!( + page.chunks().len() > 1, + "the real parser-backed page must cover multiple chunks" + ); let directory = tempfile::tempdir().expect("artifact tempdir"); - let artifact_path = directory - .path() - .join("missing-term-statistics-index.sqlite"); - let control = ArtifactControl { cancelled: false }; + let artifact_path = directory.path().join("repetitive-prefix.sqlite"); let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) .expect("create artifact"); - for page in &pages { - builder.append_page(page, &control).expect("append page"); - } - let verified = finish_staged_artifact(&mut builder, &source_receipt, &control); - let connection = rusqlite::Connection::open(&artifact_path).expect("open artifact mutation"); - connection - .execute_batch("DROP INDEX term_stats_by_term;") - .expect("remove required term-statistics index"); - drop(connection); - - assert!(matches!( - CodeLexicalArtifactReaderV1::open_with_control( - &artifact_path, - &verified, - CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, - &control, - ), - Err(CodeLexicalArtifactErrorV1::Incompatible(_)) - )); + let prepared = builder + .prepare_admissible_page_prefix( + std::slice::from_ref(page), + &ArtifactControl { cancelled: false }, + ) + .expect("select repetitive page prefix"); + assert_eq!( + prepared.accepted_prefix().get(), + 1, + "a conservative pre-dedup estimate must not turn one valid page into a permanent zero prefix" + ); + let progress = builder + .append_prepared_pages( + prepared.prepared_pages(), + &ArtifactControl { cancelled: false }, + ) + .expect("exactly prepared repetitive page must commit inside every canonical limit"); + assert_eq!(progress.next_page_ordinal, 1); } #[test] @@ -1257,9 +2430,6 @@ fn disk_artifact_revision_four_is_incompatible_before_new_index_queries() { [], ) .expect("write revision-four artifact state"); - connection - .execute_batch("DROP INDEX term_stats_by_term;") - .expect("remove revision-five index"); drop(connection); assert!(matches!( @@ -1275,16 +2445,32 @@ fn disk_artifact_revision_four_is_incompatible_before_new_index_queries() { #[test] fn disk_artifact_resume_rejects_current_revision_with_wrong_term_index_shape() { - let (fixture, pages, _) = real_verified_pages(); + let (fixture, pages, source_receipt) = real_verified_pages(); let metadata = fixture.metadata.clone(); let directory = tempfile::tempdir().expect("artifact tempdir"); let artifact_path = directory.path().join("wrong-term-index-shape.sqlite"); let control = ArtifactControl { cancelled: false }; let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, metadata.clone()).expect("create"); - builder - .append_page(&pages[0], &control) - .expect("stage current-format source page"); + for page in &pages { + builder + .append_page(page, &control) + .expect("stage current-format source page"); + } + assert!(matches!( + builder + .advance_finalization(&source_receipt, 1, &control) + .expect("freeze current artifact"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); + for _ in 0..10 { + assert!(matches!( + builder + .advance_finalization(&source_receipt, 4_096, &control) + .expect("advance one statistics or serving-index step"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); + } drop(builder); let connection = rusqlite::Connection::open(&artifact_path).expect("open index mutation"); @@ -1330,18 +2516,18 @@ fn disk_artifact_finalization_refuses_inter_wake_mutation() { CodeLexicalArtifactFinalizationStepV1::Pending { .. } )); let connection = rusqlite::Connection::open(&artifact_path).expect("open artifact mutation"); - connection - .execute( - "UPDATE rows SET row = row WHERE document_id = (SELECT MIN(document_id) FROM rows)", - [], - ) - .expect("perform a structurally valid inter-wake mutation"); + assert!( + connection + .execute( + "UPDATE rows SET row = row WHERE document_id = (SELECT MIN(document_id) FROM rows)", + [], + ) + .is_err(), + "the persisted freeze must reject inter-wake mutation" + ); drop(connection); - assert!(matches!( - builder.advance_finalization(&source_receipt, 1, &control), - Err(CodeLexicalArtifactErrorV1::Corrupt(_)) - )); + finish_staged_artifact(&mut builder, &source_receipt, &control); assert_eq!( builder.progress().expect("source progress after refusal"), staged, @@ -1418,21 +2604,23 @@ fn disk_artifact_mandatory_verifier_rejects_tampered_real_source_chain() { .append_page(&pages[0], &control) .expect("append canonical first page"); let connection = rusqlite::Connection::open(&artifact_path).expect("open artifact mutation"); - connection - .execute( - "UPDATE source_pages SET page_digest = ?1, cumulative_digest = ?2, import_dictionary_digest = ?3 WHERE page_ordinal = 0", - [ - digest_id::('1').as_str(), - digest_id::('2').as_str(), - digest_id::('3').as_str(), - ], - ) - .expect("tamper persisted page chain"); + assert!( + connection + .execute( + "UPDATE source_pages SET page_digest = ?1, cumulative_digest = ?2, import_dictionary_digest = ?3 WHERE page_ordinal = 0", + [ + digest_id::('1').as_str(), + digest_id::('2').as_str(), + digest_id::('3').as_str(), + ], + ) + .is_err(), + "source-page authority is immutable from admission" + ); drop(connection); - assert!(matches!( - builder.append_page(&pages[1], &control), - Err(CodeLexicalArtifactErrorV1::Corrupt(_)) - )); + builder + .append_page(&pages[1], &control) + .expect("append canonical successor after denied tamper"); } #[test] @@ -1468,7 +2656,7 @@ fn disk_artifact_seal_is_terminal_and_refuses_page_replay() { } #[test] -fn disk_artifact_first_finalize_rejects_self_attesting_derived_mutation() { +fn disk_artifact_preseal_gate_denies_external_derived_mutation() { let (fixture, pages, source_receipt) = real_verified_pages(); let metadata = fixture.metadata.clone(); let directory = tempfile::tempdir().expect("artifact tempdir"); @@ -1496,61 +2684,58 @@ fn disk_artifact_first_finalize_rejects_self_attesting_derived_mutation() { .expect("import evidence count"); assert!(original_term_postings > 0); assert!(original_imports > 0); - let mut row = original_row.clone(); - row.push(b' '); - connection - .execute( - "UPDATE rows SET row = ?1 WHERE document_id = (SELECT MIN(document_id) FROM rows)", - [row], - ) - .expect("mutate derived row before first seal"); - connection - .execute("DELETE FROM term_postings", []) - .expect("remove derived term postings before first seal"); + let mut mutated_row = original_row.clone(); + mutated_row.push(b' '); + let row_mutation = connection.execute( + "UPDATE rows SET row = ?1 WHERE document_id = (SELECT MIN(document_id) FROM rows)", + [mutated_row], + ); + let posting_mutation = connection.execute("DELETE FROM term_postings", []); + let row_insertion = connection.execute( + "INSERT INTO rows(document_id, chunk_id, row) VALUES (?1, 'external-conflict', X'7b7d')", + [i64::MAX], + ); + assert!( + row_mutation.is_err(), + "schema-time mutation authority must deny external row updates before finalization" + ); + assert!( + posting_mutation.is_err(), + "schema-time mutation authority must deny external posting deletes before finalization" + ); + assert!( + row_insertion.is_err(), + "schema-time mutation authority must deny external row inserts before finalization" + ); let integrity: String = connection .query_row("PRAGMA quick_check(1)", [], |row| row.get(0)) .expect("SQLite integrity check"); assert_eq!(integrity, "ok"); drop(connection); - assert!(matches!( - builder.advance_finalization(&source_receipt, 128, &control), - Err(CodeLexicalArtifactErrorV1::Corrupt(_)) - )); - - // A corrupted staging file must fail closed. Recovery explicitly restages - // trusted pages into a fresh artifact; bounded finalization never replays - // the source just to overwrite mutable derived rows. - let recovered_path = directory.path().join("recovered-preseal-artifact.sqlite"); - let mut recovered = CodeLexicalArtifactBuilderV1::create(&recovered_path, fixture.metadata) - .expect("create fresh recovery artifact"); - for page in &pages { - recovered - .append_page(page, &control) - .expect("restage trusted page"); - } - let verified = finish_staged_artifact(&mut recovered, &source_receipt, &control); + let verified = finish_staged_artifact(&mut builder, &source_receipt, &control); CodeLexicalArtifactReaderV1::open_with_control( - &recovered_path, + &artifact_path, &verified, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, &control, ) - .expect("freshly staged artifact verifies"); - let connection = rusqlite::Connection::open(&recovered_path).expect("inspect recovery"); + .expect("denied mutation preserves a readable finalized artifact"); + let connection = + rusqlite::Connection::open(&artifact_path).expect("inspect finalized artifact"); let rebuilt_row: Vec = connection .query_row( "SELECT row FROM rows ORDER BY document_id LIMIT 1", [], |row| row.get(0), ) - .expect("recovered artifact row"); + .expect("finalized artifact row"); let rebuilt_term_postings: i64 = connection .query_row("SELECT COUNT(*) FROM term_postings", [], |row| row.get(0)) - .expect("recovered term posting count"); + .expect("finalized term posting count"); let rebuilt_imports: i64 = connection .query_row("SELECT COUNT(*) FROM import_evidence", [], |row| row.get(0)) - .expect("recovered import evidence count"); + .expect("finalized import evidence count"); assert_eq!(rebuilt_row, original_row); assert_eq!(rebuilt_term_postings, original_term_postings); assert_eq!(rebuilt_imports, original_imports); @@ -1623,33 +2808,34 @@ fn disk_artifact_corruption_is_sticky_across_finalize_and_reopen_retries() { ) .expect("artifact row"); row.push(b' '); - connection - .execute( - "UPDATE rows SET row = ?1 WHERE document_id = (SELECT MIN(document_id) FROM rows)", - [row], - ) - .expect("mutate artifact row without damaging SQLite structure"); + assert!( + connection + .execute( + "UPDATE rows SET row = ?1 WHERE document_id = (SELECT MIN(document_id) FROM rows)", + [row], + ) + .is_err(), + "sealed artifacts deny base-row mutation" + ); let integrity: String = connection .query_row("PRAGMA quick_check(1)", [], |row| row.get(0)) .expect("SQLite integrity check"); assert_eq!(integrity, "ok"); drop(connection); - for _ in 0..2 { - assert!(matches!( - builder.finalize(&source_receipt, &control), - Err(CodeLexicalArtifactErrorV1::Corrupt(_)) - )); - assert!(matches!( - CodeLexicalArtifactReaderV1::open_with_control( - &artifact_path, - &verified, - CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, - &control, - ), - Err(CodeLexicalArtifactErrorV1::Corrupt(_)) - )); - } + assert_eq!( + builder + .finalize(&source_receipt, &control) + .expect("denied mutation preserves sealed receipt"), + verified + ); + CodeLexicalArtifactReaderV1::open_with_control( + &artifact_path, + &verified, + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, + &control, + ) + .expect("denied mutation preserves readable artifact"); } #[test] @@ -1817,6 +3003,270 @@ fn staged_row_cardinality(artifact_path: &Path) -> (u64, u64) { ) } +#[test] +fn disk_artifact_receipt_failure_rolls_back_prior_page_rows_and_receipts() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + assert!(pages.len() >= 2, "fixture must emit a multi-page batch"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("receipt-failure-batch.sqlite"); + let control = ArtifactControl { cancelled: false }; + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) + .expect("create artifact"); + let connection = rusqlite::Connection::open(&artifact_path).expect("install receipt failpoint"); + connection + .execute_batch( + "CREATE TRIGGER fail_second_page_receipt + BEFORE INSERT ON source_pages + WHEN NEW.page_ordinal = 1 + BEGIN SELECT RAISE(ABORT, 'forced receipt failure'); END;", + ) + .expect("create receipt failpoint"); + drop(connection); + + assert!(builder.append_pages(&pages[..2], &control).is_err()); + assert_eq!( + builder + .progress() + .expect("progress after receipt failure") + .next_page_ordinal, + 0 + ); + assert_eq!( + staged_row_cardinality(&artifact_path).0, + 0, + "receipt failure must roll back all prior relational writes" + ); +} + +#[test] +fn disk_artifact_committed_batch_replays_after_restart_without_duplicate_rows() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + assert!(pages.len() >= 2, "fixture must emit a multi-page batch"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("commit-ack-gap.sqlite"); + let metadata = fixture.metadata; + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, metadata.clone()) + .expect("create artifact"); + let control = ArtifactControl { cancelled: false }; + builder + .append_pages(&pages[..2], &control) + .expect("commit exact ordered batch"); + assert_eq!( + builder + .progress() + .expect("durable progress after commit") + .next_page_ordinal, + 2, + "the whole batch must be durable before source acknowledgement" + ); + drop(builder); + let mut builder = CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &artifact_path, + metadata, + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("resume after a process boundary"); + let replay = builder + .append_pages(&pages[..2], &control) + .expect("replay batch after source cursor was not acknowledged"); + assert_eq!(replay.next_page_ordinal, 2); + assert_eq!( + staged_row_cardinality(&artifact_path).0, + pages[0].chunk_count() + pages[1].chunk_count(), + "restart replay must not duplicate relational rows" + ); +} + +#[test] +fn disk_artifact_batch_ledger_charges_every_parallel_preparation_upper_bound() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + assert!(pages.len() >= 2, "fixture must emit a multi-page batch"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let builder = CodeLexicalArtifactBuilderV1::create( + directory.path().join("batch-ledger.sqlite"), + fixture.metadata, + ) + .expect("create artifact"); + let control = ArtifactControl { cancelled: false }; + let pages = pages.as_slice(); + assert!( + pages.iter().any(|page| !page.imports().is_empty()), + "ledger fixture must retain import evidence" + ); + assert!( + pages.iter().flat_map(|page| page.chunks()).any(|chunk| { + chunk.chunk().sanitized_text.as_str().len() >= 3 + && (!chunk.chunk().subtokens.is_empty() || !chunk.chunk().exact_terms.is_empty()) + }), + "ledger fixture must exercise term and n-gram preparation" + ); + let source_retained = pages + .iter() + .try_fold(0usize, |total, page| { + total.checked_add(page.retained_owned_bytes()) + }) + .expect("retained page sum"); + let conservative_charge = builder + .page_batch_ledger_charge_bytes(pages) + .expect("batch ledger charge"); + let prepared = builder + .prepare_pages(pages, &control) + .expect("prepare deterministic ledger probe"); + let prepared_retained = prepared + .iter() + .map(|page| page.retained_owned_bytes()) + .sum::(); + let effective_workers = + tracedecay_code_index::parallelism::indexing_workers().min(prepared.len()); + let mut scratch = prepared + .iter() + .map(|page| page.preparation_scratch_bytes()) + .collect::>(); + scratch.sort_unstable_by(|left, right| right.cmp(left)); + let active_scratch = scratch.into_iter().take(effective_workers).sum::(); + let exact_prepared_charge = source_retained + prepared_retained + active_scratch; + assert!( + conservative_charge >= exact_prepared_charge, + "pre-preparation admission undercounted live batch components: conservative={conservative_charge}, source={source_retained}, prepared={prepared_retained}, active_scratch={active_scratch}, workers={effective_workers}, exact={exact_prepared_charge}" + ); + for (source, prepared) in pages.iter().zip(&prepared) { + let conservative = builder + .page_ledger_charge_bytes(source) + .expect("one-page ledger charge"); + let exact = prepared + .ledger_charge_bytes() + .expect("prepared page charge"); + assert!( + conservative >= exact, + "page {} preflight undercounted exact components: conservative={conservative}, source={}, prepared={}, scratch={}, exact={exact}", + source.page_ordinal(), + prepared.source_retained_bytes(), + prepared.retained_owned_bytes(), + prepared.preparation_scratch_bytes(), + ); + } +} + +#[test] +fn disk_artifact_page_ledger_charges_live_ngram_map_and_encoded_shard_overlap() { + let fixture = real_lexical_source_fixture_from_sources(vec![( + "file.artifact".to_owned(), + "src/artifact.ts".to_owned(), + b"export functionabcdefghijklmnopqrstuvwxyz0123456789(value: string) { return value + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ9876543210'; }\n".to_vec(), + )]); + let (pages, _) = drain_verified_pages(&fixture, 128); + assert_eq!( + pages.len(), + 1, + "the adversarial fixture must occupy one page" + ); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let builder = CodeLexicalArtifactBuilderV1::create( + directory.path().join("ngram-overlap-ledger.sqlite"), + fixture.metadata, + ) + .expect("create artifact"); + let prepared = builder + .prepare_pages(&pages, &ArtifactControl { cancelled: false }) + .expect("prepare adversarial ngram page"); + let page = &pages[0]; + let prepared = &prepared[0]; + let logical_memberships = page + .chunks() + .iter() + .map(|chunk| { + chunk + .chunk() + .sanitized_text + .as_str() + .as_bytes() + .windows(3) + .collect::>() + .len() + }) + .sum::(); + let distinct_keys = page + .chunks() + .iter() + .flat_map(|chunk| chunk.chunk().sanitized_text.as_str().as_bytes().windows(3)) + .collect::>() + .len(); + // A distinct key owns an ordered-map node and one Roaring container; each + // membership owns sparse-container capacity while encoded output already + // accumulates. These deliberately conservative per-item bounds are below + // the production charge, but far above the unrelated one-document scratch. + let strict_live_map_lower_bound = distinct_keys + .checked_mul(64) + .and_then(|bytes| bytes.checked_add(logical_memberships.saturating_mul(128))) + .expect("ledger lower bound"); + assert!( + prepared.preparation_scratch_bytes() >= strict_live_map_lower_bound, + "aggregation scratch must coexist with encoded shards: charged={}, strict map lower bound={strict_live_map_lower_bound}, distinct_keys={distinct_keys}, memberships={logical_memberships}", + prepared.preparation_scratch_bytes(), + ); +} + +#[test] +fn disk_artifact_one_page_wrapper_matches_the_batch_path() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let control = ArtifactControl { cancelled: false }; + let mut wrapper = CodeLexicalArtifactBuilderV1::create( + directory.path().join("one-page-wrapper.sqlite"), + fixture.metadata.clone(), + ) + .expect("create wrapper artifact"); + let mut batch = CodeLexicalArtifactBuilderV1::create( + directory.path().join("one-page-batch.sqlite"), + fixture.metadata, + ) + .expect("create batch artifact"); + + assert_eq!( + wrapper + .append_page(&pages[0], &control) + .expect("append through wrapper"), + batch + .append_pages(&pages[..1], &control) + .expect("append through batch path") + ); +} + +#[test] +fn disk_artifact_page_shards_have_batch_width_independent_receipts() { + let (fixture, pages, source_receipt) = real_verified_pages_with_maximum_page_chunks(1); + assert!( + pages.len() >= 2, + "fixture must exercise multiple source pages" + ); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let control = ArtifactControl { cancelled: false }; + let mut one_by_one = CodeLexicalArtifactBuilderV1::create( + directory.path().join("page-shards-one-by-one.sqlite"), + fixture.metadata.clone(), + ) + .expect("create one-page-width artifact"); + for page in &pages { + one_by_one + .append_page(page, &control) + .expect("append one source page"); + } + let mut batched = CodeLexicalArtifactBuilderV1::create( + directory.path().join("page-shards-batched.sqlite"), + fixture.metadata, + ) + .expect("create batched artifact"); + batched + .append_pages(&pages, &control) + .expect("append every source page atomically"); + + let one_by_one = finish_staged_artifact(&mut one_by_one, &source_receipt, &control); + let batched = finish_staged_artifact(&mut batched, &source_receipt, &control); + assert_eq!(one_by_one.artifact_digest(), batched.artifact_digest()); + assert_eq!(one_by_one.section_digests(), batched.section_digests()); +} + #[test] fn disk_artifact_budget_refusal_precedes_progress_and_accepts_boundary() { let (fixture, pages, source_receipt) = real_verified_pages_with_maximum_page_chunks(1); @@ -1859,7 +3309,7 @@ fn disk_artifact_budget_refusal_precedes_progress_and_accepts_boundary() { .len(); assert!(matches!( refused.append_page(&pages[0], &control), - Err(CodeLexicalArtifactErrorV1::Contract(_)) + Err(CodeLexicalArtifactErrorV1::BatchTooLarge { .. }) )); assert_eq!( refused @@ -2045,10 +3495,15 @@ fn disk_artifact_bounded_work_budget_exhaustion_resumes_activation() { // source or duplicating rows. Their SQLite cursor is deliberately // durable, so retries are not required to preserve the staging file's // byte size. + assert!(matches!( + builder + .advance_finalization(&source_receipt, 1, &control) + .expect("persist immutable finalization freeze"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); for round in 0..4 { - // The fifth checkpoint is the first one after the finalization marker - // is durable. Earlier refusal is intentionally mutation-free and does - // not freeze append admission. + // Every retry starts from the already durable freeze and may yield + // without replaying source or weakening immutable base authority. let exhausted = BudgetExhaustedAtObservation::new(5); let outcome = builder.advance_finalization(&source_receipt, usize::MAX, &exhausted); assert!( @@ -2405,9 +3860,21 @@ fn sealed_page_retained_bytes_include_digest_identities() { + evidence.local_name.as_ref().map_or(0, String::capacity) }, ); + let symbol_display_bytes = page.symbol_displays().iter().fold( + page.symbol_display_capacity() + .saturating_mul(std::mem::size_of::< + Option, + >()), + |bytes, display| { + bytes.saturating_add(display.as_ref().map_or( + 0, + VerifiedSealedLexicalSymbolDisplayV1::retained_owned_bytes, + )) + }, + ); assert_eq!( page.retained_owned_bytes(), - payload_bytes + 8 * sha256_digest_len, + payload_bytes + symbol_display_bytes + 8 * sha256_digest_len, "page {} retained bytes must include its eight digest identity strings", page.page_ordinal() ); diff --git a/crates/tracedecay-runtime-core/Cargo.toml b/crates/tracedecay-runtime-core/Cargo.toml index b13a7311a6..007a2d1a41 100644 --- a/crates/tracedecay-runtime-core/Cargo.toml +++ b/crates/tracedecay-runtime-core/Cargo.toml @@ -54,6 +54,7 @@ test-transport = ["tracedecay-rusqlite-runtime/test-transport"] test-helpers = [] [dependencies] +aho-corasick = "1.1.5" hotpath.workspace = true tracedecay-automation = { path = "../tracedecay-automation", version = "0.1.0" } tracedecay-capture = { path = "../tracedecay-capture", version = "0.1.0" } diff --git a/crates/tracedecay-runtime-core/src/privacy/detect.rs b/crates/tracedecay-runtime-core/src/privacy/detect.rs index 227f00eaae..2c0350b3fa 100644 --- a/crates/tracedecay-runtime-core/src/privacy/detect.rs +++ b/crates/tracedecay-runtime-core/src/privacy/detect.rs @@ -14,12 +14,12 @@ use super::assessment::{ validate_assessment, }; use super::detector_kernel::{ - CredentialPattern, CredentialPatternKind, CredentialPatternProfile, CredentialRuleSetError, - JsonPathSegment, JsonVisitMut, NormalizedSensitiveKey, SensitiveKeyPolicy, - compile_credential_patterns, entropy_bits_per_mille, high_entropy_ranges, - visit_json_object_keys, visit_sensitive_json_mut, + CredentialPatternKind, CredentialPatternProfile, CredentialRuleSetError, JsonPathSegment, + JsonVisitMut, NormalizedSensitiveKey, SensitiveKeyPolicy, entropy_bits_per_mille, + high_entropy_ranges, visit_json_object_keys, visit_sensitive_json_mut, }; use super::length_prefixed_sha256_hex; +use super::rules::{CredentialPatternSet, compile_credential_pattern_set}; const REDACTED_EXACT: &str = "[TraceDecay redacted: exact credential]"; const REDACTED_BEARER: &str = "[TraceDecay redacted: bearer token]"; @@ -679,12 +679,12 @@ fn memory_fact_receipt( pub(super) fn redact_text( text: &mut String, path: &str, - patterns: &[CredentialPattern], + patterns: &CredentialPatternSet, findings: &mut Vec, action: SanitizationActionV1, ) -> bool { let mut candidates = Vec::new(); - for pattern in patterns { + for (pattern, keywords_present) in patterns.iter().zip(patterns.keyword_presence(text)) { let (detector, confidence, replacement) = pattern_metadata(pattern.kind()); let priority = match pattern.kind() { CredentialPatternKind::PrivateKey => 4, @@ -693,7 +693,7 @@ pub(super) fn redact_text( }; candidates.extend( pattern - .ranges(text) + .ranges_when_keywords_present(text, keywords_present) .into_iter() .map(|range| (range, detector, confidence, replacement, priority)), ); @@ -770,12 +770,12 @@ pub(super) fn redact_text( changed } -pub(super) fn credential_patterns() -> Result<&'static [CredentialPattern], DetectionError> { - static PATTERNS: OnceLock, CredentialRuleSetError>> = +pub(super) fn credential_patterns() -> Result<&'static CredentialPatternSet, DetectionError> { + static PATTERNS: OnceLock> = OnceLock::new(); PATTERNS - .get_or_init(|| compile_credential_patterns(CredentialPatternProfile::Observation)) - .as_deref() + .get_or_init(|| compile_credential_pattern_set(CredentialPatternProfile::Observation)) + .as_ref() .map_err(|_| DetectionError::Initialization) } diff --git a/crates/tracedecay-runtime-core/src/privacy/rules.rs b/crates/tracedecay-runtime-core/src/privacy/rules.rs index 7d09a36d77..5ea13d5095 100644 --- a/crates/tracedecay-runtime-core/src/privacy/rules.rs +++ b/crates/tracedecay-runtime-core/src/privacy/rules.rs @@ -23,8 +23,9 @@ use std::borrow::Cow; use std::collections::BTreeSet; -use std::ops::Range; +use std::ops::{Deref, Range}; +use aho_corasick::{AhoCorasick, AhoCorasickBuilder}; use regex::{Captures, Match, Regex}; use serde::Deserialize; use thiserror::Error; @@ -119,6 +120,118 @@ pub(crate) enum CredentialRuleSetError { rule_id: String, reason: &'static str, }, + #[error("credential keyword matcher could not be compiled")] + KeywordMatcher, +} + +/// One compiled ruleset and its shared ASCII-case-insensitive keyword gate. +/// +/// The catalogue evaluates every rule against the same source value. Compiling +/// all keyword preconditions into one automaton makes that a single source +/// scan, while the ordered pattern slice remains the only authority for rule +/// precedence and redaction decisions. +pub(crate) struct CredentialPatternSet { + patterns: Vec, + keyword_matcher: KeywordMatcher, +} + +impl CredentialPatternSet { + fn new(patterns: Vec) -> Result { + let keyword_matcher = KeywordMatcher::from_patterns(&patterns)?; + Ok(Self { + patterns, + keyword_matcher, + }) + } + + pub(crate) fn keyword_presence(&self, text: &str) -> Vec { + self.keyword_matcher.presence(text, &self.patterns) + } + + #[cfg(test)] + fn unique_keyword_count(&self) -> usize { + self.keyword_matcher.pattern_count() + } +} + +impl Deref for CredentialPatternSet { + type Target = [CredentialPattern]; + + fn deref(&self) -> &Self::Target { + &self.patterns + } +} + +/// A single Aho–Corasick scan maps every keyword hit back to the rules whose +/// precondition it satisfies. `None` is the truthful empty-keyword case: those +/// rules all run and no matcher needs to scan the source. +struct KeywordMatcher { + matcher: Option, + keyword_rules: Vec>, +} + +impl KeywordMatcher { + fn from_patterns(patterns: &[CredentialPattern]) -> Result { + let mut keyword_rules = BTreeSet::new(); + for pattern in patterns { + keyword_rules.extend(pattern.keywords.iter().cloned()); + } + let keywords = keyword_rules.into_iter().collect::>(); + let mut rules_by_keyword = vec![Vec::new(); keywords.len()]; + for (rule_index, pattern) in patterns.iter().enumerate() { + for keyword in &pattern.keywords { + let keyword_index = keywords + .binary_search(keyword) + .map_err(|_| CredentialRuleSetError::KeywordMatcher)?; + rules_by_keyword[keyword_index].push(rule_index); + } + } + + let matcher = if keywords.is_empty() { + None + } else { + let mut builder = AhoCorasickBuilder::new(); + builder.ascii_case_insensitive(true); + Some( + builder + .build(&keywords) + .map_err(|_| CredentialRuleSetError::KeywordMatcher)?, + ) + }; + Ok(Self { + matcher, + keyword_rules: rules_by_keyword, + }) + } + + fn presence(&self, text: &str, patterns: &[CredentialPattern]) -> Vec { + let mut presence = patterns + .iter() + .map(|pattern| pattern.keywords.is_empty()) + .collect::>(); + let mut remaining = presence.iter().filter(|present| !**present).count(); + let Some(matcher) = &self.matcher else { + return presence; + }; + + for found in matcher.find_overlapping_iter(text.as_bytes()) { + for &rule_index in &self.keyword_rules[found.pattern().as_usize()] { + if !presence[rule_index] { + presence[rule_index] = true; + remaining -= 1; + } + } + if remaining == 0 { + break; + } + } + presence + } + + #[cfg(test)] + fn pattern_count(&self) -> usize { + self.keyword_rules.len() + } } /// One compiled credential rule. @@ -165,10 +278,13 @@ impl CredentialPattern { // Keyword gate first, exactly as upstream orders it: it is both the // rule's precondition and the cheapest possible reject, which is what // keeps a 200-rule catalogue affordable inline at ingest. - if !self.keywords_present(text) || !self.regex.is_match(text) { + let keywords_present = self.keywords_present(text); + if !keywords_present || !self.regex.is_match(text) { return false; } - !self.ranges(text).is_empty() + !self + .ranges_when_keywords_present(text, keywords_present) + .is_empty() } fn keywords_present(&self, text: &str) -> bool { @@ -179,17 +295,22 @@ impl CredentialPattern { .any(|keyword| contains_ignore_ascii_case(text, keyword)) } - /// Byte ranges to redact. The whole match, not just the secret group: a - /// sanitizer must not leave secret bytes behind, and the context a rule - /// matched on is itself worth removing. + /// Test-only direct range surface. The whole match, not just the secret + /// group: a sanitizer must not leave secret bytes behind, and the context + /// a rule matched on is itself worth removing. + #[cfg(test)] pub fn ranges(&self, text: &str) -> Vec> { - // Same keyword gate as `is_match`, and for the same reason: some - // rules (`sourcegraph-access-token` among them) accept a bare match - // that is only safe when their keyword precondition holds. - // `redact_text` calls `ranges` directly, bypassing `is_match`, so the - // gate has to live here too or a keyword-gated rule fires unguarded - // on every caller that doesn't separately check `is_match` first. - if !self.keywords_present(text) { + // Some rules (`sourcegraph-access-token`) accept a bare match that is + // safe only when their keyword precondition holds. + self.ranges_when_keywords_present(text, self.keywords_present(text)) + } + + pub(crate) fn ranges_when_keywords_present( + &self, + text: &str, + keywords_present: bool, + ) -> Vec> { + if !keywords_present { return Vec::new(); } if let Some(min_len) = self.assignment_min_len { @@ -342,6 +463,12 @@ pub(crate) fn compile_credential_patterns( Ok(patterns) } +pub(crate) fn compile_credential_pattern_set( + profile: CredentialPatternProfile, +) -> Result { + CredentialPatternSet::new(compile_credential_patterns(profile)?) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RuleOrigin { /// Upstream schema: kind is inferred, every rule runs in every profile. @@ -1018,6 +1145,10 @@ mod tests { compile_credential_patterns(profile).expect("credential ruleset compiles") } + fn pattern_set(profile: CredentialPatternProfile) -> CredentialPatternSet { + compile_credential_pattern_set(profile).expect("credential ruleset compiles") + } + fn rule<'a>(patterns: &'a [CredentialPattern], id: &str) -> &'a CredentialPattern { patterns .iter() @@ -1308,6 +1439,37 @@ mod tests { ); } + #[test] + fn combined_keyword_matcher_preserves_ascii_gate_parity_on_overlap() { + let compiled = pattern_set(CredentialPatternProfile::Observation); + let source = "sourcegraph sourceGRAPH sgP_\u{212A}ey"; + + let expected = compiled + .iter() + .map(|pattern| pattern.keywords_present(source)) + .collect::>(); + + assert_eq!(compiled.keyword_presence(source), expected); + } + + #[test] + fn combined_keyword_matcher_uses_one_authority_for_a_large_no_match_source() { + let compiled = pattern_set(CredentialPatternProfile::Observation); + let source = "\0".repeat(8 * 1024 * 1024); + + let expected = compiled + .iter() + .map(|pattern| pattern.keywords.is_empty()) + .collect::>(); + + assert_eq!(compiled.keyword_presence(&source), expected); + assert_eq!( + compiled.keyword_matcher.pattern_count(), + compiled.unique_keyword_count(), + "one combined matcher must own every distinct keyword" + ); + } + /// `regexTarget` steers the allowlist regexes only. A stopword read from the /// match instead of the secret lets the very keyword that triggered a rule /// excuse it — "auth" is both this rule's trigger and one of its stopwords. diff --git a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs index 3266a0b819..30dd5f8f8f 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs @@ -33,8 +33,9 @@ use super::detect::{ SanitizationActionV1, SanitizationDetectorOriginV1, SanitizationFindingV1, credential_patterns, redact_text, }; -use super::detector_kernel::{CredentialPattern, NormalizedSensitiveKey, SensitiveKeyPolicy}; +use super::detector_kernel::{NormalizedSensitiveKey, SensitiveKeyPolicy}; use super::length_prefixed_sha256_hex; +use super::rules::CredentialPatternSet; use super::structured::{ ParsedStructuredTextV1, StructuredSanitizationError, StructuredSanitizationLimits, StructuredTextFieldV1, StructuredTextFormatV1, StructuredTextParseFailureV1, @@ -265,7 +266,7 @@ pub(crate) fn sanitize_structured_text( }) } -fn raw_only(raw: &str, patterns: &[CredentialPattern]) -> StructuredTextSanitizationV1 { +fn raw_only(raw: &str, patterns: &CredentialPatternSet) -> StructuredTextSanitizationV1 { let mut sanitized_text = raw.to_owned(); let mut findings = Vec::new(); redact_text( @@ -290,7 +291,7 @@ fn raw_only(raw: &str, patterns: &[CredentialPattern]) -> StructuredTextSanitiza /// then emit a typed quarantine finding so every durable caller rejects it. fn quarantined_structured_text( raw: &str, - patterns: &[CredentialPattern], + patterns: &CredentialPatternSet, ) -> StructuredTextSanitizationV1 { let mut sanitized = raw_only(raw, patterns); sanitized @@ -329,7 +330,7 @@ fn line_candidates( raw: &str, fields: &[StructuredTextFieldV1], policy: &ConfiguredSensitiveKeyPolicy<'_>, - patterns: &[CredentialPattern], + patterns: &CredentialPatternSet, ) -> Vec { let mut candidates = Vec::new(); for field in fields { @@ -366,7 +367,7 @@ fn line_candidates( /// Detects whether an already-decoded value carries a credential the encoded /// bytes hid. `Authorization=Bearer%20…` only looks like a bearer token once /// the percent escapes are resolved. -fn trips_a_detector(decoded: &str, patterns: &[CredentialPattern]) -> bool { +fn trips_a_detector(decoded: &str, patterns: &CredentialPatternSet) -> bool { let mut probe = decoded.to_owned(); let mut ignored = Vec::new(); redact_text( @@ -382,7 +383,7 @@ fn tree_candidates( raw: &str, parsed: &ParsedStructuredTextV1, policy: &ConfiguredSensitiveKeyPolicy<'_>, - patterns: &[CredentialPattern], + patterns: &CredentialPatternSet, quarantine_findings: &mut Vec, ) -> Vec { let mut sensitive = Vec::new(); @@ -422,7 +423,7 @@ fn tree_candidates( fn collect_tree_fields( value: &Value, policy: &ConfiguredSensitiveKeyPolicy<'_>, - patterns: &[CredentialPattern], + patterns: &CredentialPatternSet, sensitive: &mut Vec<(String, String, SanitizationDetectorOriginV1)>, quarantine_findings: &mut Vec, ) { diff --git a/crates/tracedecay-search-eval/src/candidate_output.rs b/crates/tracedecay-search-eval/src/candidate_output.rs index 999d153a5c..ae3463b297 100644 --- a/crates/tracedecay-search-eval/src/candidate_output.rs +++ b/crates/tracedecay-search-eval/src/candidate_output.rs @@ -2705,7 +2705,7 @@ fn publish_corpus_with_scale( if indexable { captured.push(CodeIndexCapturedFileV1 { file_occurrence_id, - sanitized_bytes: bytes, + sanitized_bytes: Arc::from(bytes), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }); } @@ -2835,12 +2835,12 @@ fn publish_corpus_with_scale( "incremental fixture corpus document is not eligible".to_owned(), ) })?; - if changed.sanitized_bytes == after_bytes { + if changed.sanitized_bytes.as_ref() == after_bytes.as_slice() { return Err(CandidateOutputError::Contract( "incremental before/after fixture bytes are identical".to_owned(), )); } - changed.sanitized_bytes = after_bytes; + changed.sanitized_bytes = Arc::from(after_bytes); let changed_digest = content_digest(&changed.sanitized_bytes); let snapshot_file = incremental_snapshot .files diff --git a/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs b/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs index 5704e53463..d25dcd968c 100644 --- a/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs +++ b/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs @@ -1,6 +1,6 @@ -use std::collections::BTreeSet; use std::fs; use std::path::Path; +use std::{collections::BTreeSet, sync::Arc}; use tracedecay_code_index::chunks::content_digest; use tracedecay_code_index::production::{ @@ -46,7 +46,7 @@ pub(super) fn prove_cancellation( }); captured.push(CodeIndexCapturedFileV1 { file_occurrence_id, - sanitized_bytes: bytes.clone(), + sanitized_bytes: Arc::from(bytes.clone()), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }); let snapshot = SanitizedCodeSnapshotV1 { diff --git a/crates/tracedecay-usecases/src/observability.rs b/crates/tracedecay-usecases/src/observability.rs index 892f981f90..bf42a1b4e1 100644 --- a/crates/tracedecay-usecases/src/observability.rs +++ b/crates/tracedecay-usecases/src/observability.rs @@ -38,7 +38,7 @@ pub use delivery_recorder::{ }; pub use delivery_settlement::{DeliverySettlementAuthorityV1, DeliverySettlementEmissionV1}; pub use emit::{ - record_adoption_eligibility, record_adoption_outcome, record_index, record_latency, + emit_index, record_adoption_eligibility, record_adoption_outcome, record_index, record_latency, record_operation_resource, record_retrieval_query, record_storage, }; pub use execution_emit::{ diff --git a/crates/tracedecay-usecases/src/observability/emit.rs b/crates/tracedecay-usecases/src/observability/emit.rs index c07cafae40..3130f3c3e1 100644 --- a/crates/tracedecay-usecases/src/observability/emit.rs +++ b/crates/tracedecay-usecases/src/observability/emit.rs @@ -36,6 +36,8 @@ use tracedecay_global_db::RegisteredGlobalDb; use crate::event_lane::record_observability; +use super::{BoundedObservabilityProducerV1, ObservabilityEmissionOutcomeV1}; + const SCHEMA_REVISION: u32 = 1; const CONFIGURATION_REVISION: &str = "registered-project-session.v1"; @@ -617,6 +619,20 @@ pub async fn record_index( record_observability(db, envelope).await } +/// Offers one code-index generation lifecycle observation to the mounted +/// bounded producer without waiting for project-store persistence. +pub fn emit_index( + producer: &BoundedObservabilityProducerV1, + observation: IndexObservedV1, +) -> Result { + let envelope = index_envelope( + &producer.identity().authorized_scope_ref, + now_micros().0, + observation, + )?; + producer.try_emit(envelope) +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/dashboard/codegen/schemas/dashboard-contracts.schema.json b/dashboard/codegen/schemas/dashboard-contracts.schema.json index b9396acc49..07f86e3add 100644 --- a/dashboard/codegen/schemas/dashboard-contracts.schema.json +++ b/dashboard/codegen/schemas/dashboard-contracts.schema.json @@ -2167,6 +2167,205 @@ "description": "Strongly typed canonical identity: `CatalogGenerationId`.", "type": "string" }, + "CodeIndexBuildBlockedReasonV1": { + "description": "A typed reason an otherwise active generation cannot make durable progress.", + "enum": [ + "resident_memory", + "source_unavailable", + "artifact_store_unavailable", + "retry_backoff" + ], + "type": "string" + }, + "CodeIndexBuildPhaseV1": { + "description": "The durable build phase whose committed boundary the dashboard is reading.\n\nA phase is not inferred from scheduler state. The mounted registry publishes\nthe exact phase that owns the active generation.", + "enum": [ + "source_scan", + "relational_preparation", + "bulk_commit", + "index_build", + "verification", + "ready" + ], + "type": "string" + }, + "CodeIndexBuildProgressV1": { + "description": "The latest committed progress boundary for one active code-index generation.\n\nEvery count is scoped to `generation_id`. The snapshot never includes a\nstaged page: work is reported only after the batch that owns it commits.", + "properties": { + "blocked_reason": { + "anyOf": [ + { + "$ref": "#/$defs/CodeIndexBuildBlockedReasonV1" + }, + { + "type": "null" + } + ], + "description": "Reason the active generation cannot currently advance, when known." + }, + "committed_chunks": { + "description": "Search chunks committed to the artifact database.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "committed_imports": { + "description": "Import evidence rows committed to the artifact database.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "committed_pages": { + "description": "Source pages committed to the artifact database.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "committed_payload_bytes": { + "description": "Payload bytes committed to the artifact database.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "completed_files": { + "description": "Authenticated sealed-source file boundary completed by committed work.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "completed_lexical_bytes": { + "description": "Authenticated sealed lexical-byte boundary completed by committed work.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "current_batch_pages": { + "description": "Source pages in the batch currently being processed.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "current_batch_payload_bytes": { + "description": "Sealed payload bytes in the batch currently being processed.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "daemon_incarnation": { + "description": "Durable daemon-authority epoch that produced this snapshot.\n\nThis orders snapshots across daemon restarts without relying on wall\nclock time.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "elapsed_micros": { + "description": "Monotonic elapsed time for this process's active generation build.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "estimated_remaining_seconds": { + "description": "Estimated remaining build duration, absent without a truthful rate.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "files_per_second": { + "description": "Rolling committed-file throughput, absent until it is established.", + "format": "double", + "type": [ + "number", + "null" + ] + }, + "generation_id": { + "description": "Exact generation receiving the committed build work.", + "type": "string" + }, + "last_commit_latency_micros": { + "description": "Duration of the last committed SQLite batch, when one exists.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "last_progress_micros": { + "description": "Unix-epoch timestamp of the last durable progress publication.", + "format": "int64", + "type": "integer" + }, + "lexical_bytes_per_second": { + "description": "Rolling committed lexical-byte throughput, absent until it is established.", + "format": "double", + "type": [ + "number", + "null" + ] + }, + "phase": { + "$ref": "#/$defs/CodeIndexBuildPhaseV1", + "description": "Durable pipeline phase that published this snapshot." + }, + "producer_incarnation": { + "description": "Registry-minted scheduler incarnation within one daemon.\n\nA worktree retirement/remount creates a new value. `progress_epoch` is\ncomparable only when both incarnation fields match.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "progress_epoch": { + "description": "Monotonic publication epoch for replacing delayed progress reads.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "sealed_source_digest": { + "description": "Identity of the sealed source whose authenticated bounds define progress.", + "type": "string" + }, + "total_files": { + "description": "Authenticated sealed-source file bound for this generation.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "total_lexical_bytes": { + "description": "Authenticated sealed lexical-byte bound for this generation.", + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "generation_id", + "daemon_incarnation", + "producer_incarnation", + "progress_epoch", + "sealed_source_digest", + "phase", + "committed_pages", + "committed_chunks", + "committed_imports", + "committed_payload_bytes", + "completed_files", + "total_files", + "completed_lexical_bytes", + "total_lexical_bytes", + "current_batch_pages", + "current_batch_payload_bytes", + "elapsed_micros", + "last_commit_latency_micros", + "files_per_second", + "lexical_bytes_per_second", + "estimated_remaining_seconds", + "last_progress_micros", + "blocked_reason" + ], + "type": "object" + }, "CodeIndexFreshnessPayloadV1": { "properties": { "note": { @@ -2332,6 +2531,17 @@ "null" ] }, + "progress": { + "anyOf": [ + { + "$ref": "#/$defs/CodeIndexBuildProgressV1" + }, + { + "type": "null" + } + ], + "description": "Latest committed progress for the active generation, if one is mounted." + }, "repository_id": { "description": "Stable repository identity resolved by the scheduler.", "type": [ @@ -2399,7 +2609,8 @@ "last_reconcile_micros", "staleness_state", "hook_hint_count", - "coverage" + "coverage", + "progress" ], "type": "object" }, diff --git a/dashboard/src/contracts/generated.ts b/dashboard/src/contracts/generated.ts index f70ed015fa..27e37857ca 100644 --- a/dashboard/src/contracts/generated.ts +++ b/dashboard/src/contracts/generated.ts @@ -636,6 +636,48 @@ export type CapabilityId = z.infer; export const CatalogGenerationIdSchema = z.string(); export type CatalogGenerationId = z.infer; +/** A typed reason an otherwise active generation cannot make durable progress. */ +export const CodeIndexBuildBlockedReasonV1Schema = z.enum(["artifact_store_unavailable", "resident_memory", "retry_backoff", "source_unavailable"]); +export type CodeIndexBuildBlockedReasonV1 = z.infer; + +/** The durable build phase whose committed boundary the dashboard is reading. + +A phase is not inferred from scheduler state. The mounted registry publishes +the exact phase that owns the active generation. */ +export const CodeIndexBuildPhaseV1Schema = z.enum(["bulk_commit", "index_build", "ready", "relational_preparation", "source_scan", "verification"]); +export type CodeIndexBuildPhaseV1 = z.infer; + +/** The latest committed progress boundary for one active code-index generation. + +Every count is scoped to `generation_id`. The snapshot never includes a +staged page: work is reported only after the batch that owns it commits. */ +export const CodeIndexBuildProgressV1Schema = z.object({ + blocked_reason: z.union([z.lazy(() => CodeIndexBuildBlockedReasonV1Schema), z.null()]), + committed_chunks: z.number().int().safe().min(0), + committed_imports: z.number().int().safe().min(0), + committed_pages: z.number().int().safe().min(0), + committed_payload_bytes: z.number().int().safe().min(0), + completed_files: z.number().int().safe().min(0), + completed_lexical_bytes: z.number().int().safe().min(0), + current_batch_pages: z.number().int().safe().min(0), + current_batch_payload_bytes: z.number().int().safe().min(0), + daemon_incarnation: z.number().int().safe().min(0), + elapsed_micros: z.number().int().safe().min(0), + estimated_remaining_seconds: z.number().int().safe().min(0).nullable(), + files_per_second: z.number().nullable(), + generation_id: z.string(), + last_commit_latency_micros: z.number().int().safe().min(0).nullable(), + last_progress_micros: z.number().int().safe(), + lexical_bytes_per_second: z.number().nullable(), + phase: z.lazy(() => CodeIndexBuildPhaseV1Schema), + producer_incarnation: z.number().int().safe().min(0), + progress_epoch: z.number().int().safe().min(0), + sealed_source_digest: z.string(), + total_files: z.number().int().safe().min(0), + total_lexical_bytes: z.number().int().safe().min(0), +}); +export type CodeIndexBuildProgressV1 = z.infer; + export const CodeIndexFreshnessPayloadV1Schema = z.object({ note: z.string(), worktrees: z.array(z.lazy(() => CodeIndexWorktreeFreshnessV1Schema)), @@ -685,6 +727,7 @@ export const CodeIndexWorktreeFreshnessV1Schema = z.object({ hook_hint_count: z.number().int().safe().min(0).nullable(), last_reconcile_micros: z.number().int().safe().nullable(), latest_generation_id: z.string().nullable(), + progress: z.union([z.lazy(() => CodeIndexBuildProgressV1Schema), z.null()]), repository_id: z.string().nullable(), sealed_at_micros: z.number().int().safe().nullable(), snapshot_content_identity: z.string().nullable(), diff --git a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx index b5c9154cda..c0dffc5b04 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { IndexFreshness } from './IndexFreshness.tsx'; @@ -15,6 +15,7 @@ const NOW_MICROS = 1_753_003_600_000_000; afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); }); describe('Code index freshness', () => { @@ -80,6 +81,366 @@ describe('Code index freshness', () => { expect(screen.getAllByText('not reported').length).toBeGreaterThan(0); }); + it('renders exact committed build progress from the mounted generation', async () => { + renderFreshness('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: progress(), + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + + const reading = await screen.findByRole('progressbar', { name: 'Code progress' }); + expect(reading.getAttribute('value')).toBe('50'); + expect(reading.getAttribute('max')).toBe('100'); + const panel = reading.closest('[data-code-index-progress]'); + const text = panel?.textContent ?? ''; + expect(text).toContain('bulk commit · 50.0%'); + expect(text).toContain('generation.catchup.01'); + expect(text).toContain('250 / 500 files'); + expect(text).toContain('16 pages committed'); + expect(text).toContain('10k chunks committed'); + expect(text).toContain('480 imports committed'); + expect(text).toContain('16.0 MiB payload committed'); + expect(text).toContain('250 files/s · 16.0 MiB lexical bytes/s'); + expect(text).toContain('ETA 2m'); + expect(text).toContain('last commit'); + expect(text).toContain('240ms'); + }); + + it('does not render rate-dependent ETA without an established backend rate', async () => { + renderFreshness('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { + ...progress(), + files_per_second: null, + lexical_bytes_per_second: null, + estimated_remaining_seconds: 120, + blocked_reason: 'retry_backoff', + }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + + const panel = (await screen.findByRole('progressbar', { name: 'Code progress' })).closest( + '[data-code-index-progress]', + ); + const text = panel?.textContent ?? ''; + expect(text).toContain('throughput unavailable'); + expect(text).toContain('ETA unavailable'); + expect(text).toContain('blocked: retry backoff'); + expect(text).not.toContain('0 files/s'); + expect(text).not.toContain('0 B/s'); + }); + + it('removes progress when the generation is ready and has no active build', async () => { + renderFreshness('ready', { + worktrees: [{ ...worktree(), progress: null }], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + + await screen.findByText('Ready'); + expect(screen.queryByRole('progressbar', { name: 'Code progress' })).toBeNull(); + expect(screen.queryByText(/throughput unavailable/)).toBeNull(); + }); + + it('accepts a later replacement generation after epoch restart and rejects its delayed predecessor', async () => { + vi.useFakeTimers(); + const first = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: progress(), + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const replacement = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { + ...progress(), + generation_id: 'generation.catchup.02', + // The daemon restarted before this generation began, so its + // in-memory progress epoch is lower than the rendered generation. + daemon_incarnation: 2, + producer_incarnation: 1, + progress_epoch: 0, + last_progress_micros: NOW_MICROS - 1, + completed_files: 1, + }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(first), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(replacement), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(first), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('generation.catchup.01')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('generation.catchup.02')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('generation.catchup.01')).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('accepts a same-generation publication after restart and rejects a delayed pre-restart epoch', async () => { + vi.useFakeTimers(); + const beforeRestart = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { ...progress(), progress_epoch: 8 }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const afterRestart = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { + ...progress(), + daemon_incarnation: 2, + producer_incarnation: 1, + progress_epoch: 0, + last_progress_micros: NOW_MICROS - 1, + completed_files: 1, + }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(beforeRestart), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(afterRestart), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(beforeRestart), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('250 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('1 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('250 / 500 files')).toBeNull(); + }); + + it('accepts a same-daemon remounted producer and rejects its retired predecessor', async () => { + vi.useFakeTimers(); + const retired = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { ...progress(), progress_epoch: 100 }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const remounted = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { + ...progress(), + producer_incarnation: 2, + progress_epoch: 2, + completed_files: 1, + }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(retired), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(remounted), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(retired), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('250 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('1 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('250 / 500 files')).toBeNull(); + }); + + it('polls an active build each second and returns to the ready cadence', async () => { + vi.useFakeTimers(); + const active = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: progress(), + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const ready = envelope('ready', { + worktrees: [ + { + ...worktree(), + latest_generation_id: 'generation.catchup.01', + progress: null, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(active), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('generation.catchup.01')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('Ready')).toBeTruthy(); + expect(fetch).toHaveBeenCalledTimes(2); + await advanceTimers(29_998); + expect(fetch).toHaveBeenCalledTimes(2); + await advanceTimers(1); + await advanceTimers(0); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('keeps polling ready progress until the freshness envelope is ready', async () => { + vi.useFakeTimers(); + const readyProgress = { + ...progress(), + phase: 'ready', + completed_files: 500, + completed_lexical_bytes: 64 * 1024 * 1024, + estimated_remaining_seconds: 0, + }; + const transitioning = envelope('partial', { + worktrees: [ + { + ...worktree(), + latest_generation_id: readyProgress.generation_id, + staleness_state: 'stale', + coverage: 'partial', + progress: readyProgress, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const ready = envelope('ready', { + worktrees: [ + { + ...worktree(), + latest_generation_id: readyProgress.generation_id, + progress: readyProgress, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(transitioning), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('Partial')).toBeTruthy(); + expect(screen.getByText('ready · 100.0%')).toBeTruthy(); + expect(fetch).toHaveBeenCalledTimes(1); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('Ready')).toBeTruthy(); + expect(screen.getByText('ready · 100.0%')).toBeTruthy(); + expect(fetch).toHaveBeenCalledTimes(2); + await advanceTimers(29_998); + expect(fetch).toHaveBeenCalledTimes(2); + await advanceTimers(1); + await advanceTimers(0); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('keeps a mounted ready worktree without progress on the 30-second cadence', async () => { + vi.useFakeTimers(); + const ready = envelope('ready', { + worktrees: [{ ...worktree(), progress: null }], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('Ready')).toBeTruthy(); + expect(fetch).toHaveBeenCalledTimes(1); + await advanceTimers(29_999); + expect(fetch).toHaveBeenCalledTimes(1); + await advanceTimers(1); + expect(fetch).toHaveBeenCalledTimes(2); + }); + it('reports a stale generation as stale against the reference it was sealed on', async () => { renderFreshness('partial', { worktrees: [{ ...worktree(), staleness_state: 'stale', coverage: 'partial' }], @@ -120,6 +481,35 @@ function worktree() { staleness_state: 'fresh', hook_hint_count: 0, coverage: 'complete', + progress: null, + }; +} + +function progress() { + return { + generation_id: 'generation.catchup.01', + daemon_incarnation: 1, + producer_incarnation: 1, + progress_epoch: 1, + sealed_source_digest: 'sha256:sealed-source-catchup', + phase: 'bulk_commit', + committed_pages: 16, + committed_chunks: 10_000, + committed_imports: 480, + committed_payload_bytes: 16 * 1024 * 1024, + completed_files: 250, + total_files: 500, + completed_lexical_bytes: 32 * 1024 * 1024, + total_lexical_bytes: 64 * 1024 * 1024, + current_batch_pages: 4, + current_batch_payload_bytes: 4 * 1024 * 1024, + elapsed_micros: 120_000_000, + last_commit_latency_micros: 240_000, + files_per_second: 250, + lexical_bytes_per_second: 16 * 1024 * 1024, + estimated_remaining_seconds: 120, + last_progress_micros: NOW_MICROS, + blocked_reason: null, }; } @@ -143,6 +533,12 @@ function renderWith() { ); } +async function advanceTimers(milliseconds: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds); + }); +} + function envelope(domainState: string, payload: unknown) { return { schema_revision: 1, diff --git a/dashboard/src/workspaces/code/IndexFreshness.tsx b/dashboard/src/workspaces/code/IndexFreshness.tsx index c63518c9bc..138b8b8c22 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.tsx @@ -25,6 +25,7 @@ * the payload fields. */ import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { CodeIndexFreshnessPayloadV1Schema, type CodeIndexFreshnessPayloadV1, @@ -34,7 +35,9 @@ import { fetchEnvelope, type EnvelopeResult } from '../../data/query/envelope.ts import { scopeKey, scopedUrl, useScope } from '../../data/scope/store.ts'; import { authorizationState } from '../../ui/EnvelopeTruth.tsx'; import { StateChip } from '../../ui/StateChip.tsx'; -import { elideStart, formatMicrosUtc } from '../../ui/format.ts'; +import { elideStart, formatCount, formatMicrosUtc, splitBytes } from '../../ui/format.ts'; + +type CodeIndexBuildProgress = NonNullable; export function IndexFreshness() { const scope = useScope((s) => s.scope); @@ -42,7 +45,7 @@ export function IndexFreshness() { queryKey: ['code-index', 'freshness', scopeKey(scope)], queryFn: () => fetchEnvelope(scopedUrl(scope, '/api/code-index/freshness'), CodeIndexFreshnessPayloadV1Schema), - refetchInterval: 30_000, + refetchInterval: (query) => (hasActiveBuild(query.state.data) ? 1_000 : 30_000), }); return ( @@ -63,6 +66,7 @@ export function IndexFreshness() { } function FreshnessReading({ result }: { result: EnvelopeResult }) { + const latestProgress = useLatestBuildProgress(result); if (result.outcome === 'transport') { return (
@@ -83,7 +87,11 @@ function FreshnessReading({ result }: { result: EnvelopeResult ) : null} {worktrees.map((worktree) => ( - + ))} {/* The route's own sentence for why the list is the length it is. It is * the only thing distinguishing "no scheduler is attached" from "a @@ -101,7 +109,13 @@ function FreshnessReading({ result }: { result: EnvelopeResult
+ {progress ? : null}
{worktree.staleness_state ?? 'not reported'} {worktree.coverage} @@ -152,6 +167,185 @@ function WorktreeReading({ worktree }: { worktree: CodeIndexWorktreeFreshnessV1 ); } +function BuildProgressReading({ + progress, +}: { + progress: CodeIndexBuildProgress; +}) { + const percentage = progressPercentage(progress); + const hasRate = + progress.files_per_second != null && progress.lexical_bytes_per_second != null; + return ( +
+
+ Code progress + + {phaseLabel(progress.phase)} · {percentage.toFixed(1)}% + +
+ +
+ + {progress.generation_id} + + + {`${formatCount(progress.completed_files)} / ${formatCount(progress.total_files)} files`} + + {`${formatCount(progress.committed_pages)} pages committed`} + {`${formatCount(progress.committed_chunks)} chunks committed`} + {`${formatCount(progress.committed_imports)} imports committed`} + {`${formatBytes(progress.committed_payload_bytes)} payload committed`} + + {`${formatCount(progress.current_batch_pages)} pages · ${formatBytes(progress.current_batch_payload_bytes)}`} + + + {progress.files_per_second != null && progress.lexical_bytes_per_second != null + ? `${formatCount(progress.files_per_second)} files/s · ${formatBytes(progress.lexical_bytes_per_second)} lexical bytes/s` + : 'throughput unavailable'} + + + {hasRate && progress.estimated_remaining_seconds != null + ? `ETA ${formatDurationSeconds(progress.estimated_remaining_seconds)}` + : 'ETA unavailable'} + + {formatMicros(progress.last_progress_micros)} + + {progress.last_commit_latency_micros != null + ? formatDurationMicros(progress.last_commit_latency_micros) + : 'not reported'} + +
+ {progress.blocked_reason ? ( +

blocked: {blockedReasonLabel(progress.blocked_reason)}

+ ) : null} +
+ ); +} + +function hasActiveBuild(result: EnvelopeResult | undefined): boolean { + return ( + result?.outcome === 'envelope' && + (result.envelope.domain_state !== 'ready' || + result.envelope.payload.worktrees.some( + (worktree) => worktree.progress != null && worktree.progress.phase !== 'ready', + )) + ); +} + +function useLatestBuildProgress( + result: EnvelopeResult, +): ReadonlyMap { + const [latestProgress, setLatestProgress] = useState>( + () => new Map(), + ); + useEffect(() => { + if (result.outcome !== 'envelope') return; + setLatestProgress((rendered) => { + const next = new Map(rendered); + let changed = false; + for (const worktree of result.envelope.payload.worktrees) { + const incoming = worktree.progress; + const current = next.get(worktree.worktree_root); + if (!incoming) { + if ( + current && + result.envelope.domain_state === 'ready' && + worktree.latest_generation_id === current.generation_id + ) { + next.delete(worktree.worktree_root); + changed = true; + } + } else if (!current || isCurrentOrNewerProgress(incoming, current)) { + next.set(worktree.worktree_root, incoming); + changed = true; + } + } + return changed ? next : rendered; + }); + }, [result]); + return latestProgress; +} + +function isCurrentOrNewerProgress( + incoming: CodeIndexBuildProgress, + rendered: CodeIndexBuildProgress, +): boolean { + if (incoming.daemon_incarnation !== rendered.daemon_incarnation) { + return incoming.daemon_incarnation > rendered.daemon_incarnation; + } + if (incoming.producer_incarnation !== rendered.producer_incarnation) { + return incoming.producer_incarnation > rendered.producer_incarnation; + } + return incoming.progress_epoch >= rendered.progress_epoch; +} + +function progressPercentage(progress: CodeIndexBuildProgress): number { + const completed = + progress.total_lexical_bytes > 0 + ? progress.completed_lexical_bytes / progress.total_lexical_bytes + : progress.phase === 'ready' + ? 1 + : 0; + return Math.min(100, Math.max(0, completed * 100)); +} + +function phaseLabel(phase: CodeIndexBuildProgress['phase']): string { + switch (phase) { + case 'source_scan': + return 'source scan'; + case 'relational_preparation': + return 'relational preparation'; + case 'bulk_commit': + return 'bulk commit'; + case 'index_build': + return 'index build'; + case 'verification': + return 'verification'; + case 'ready': + return 'ready'; + } +} + +function blockedReasonLabel(reason: NonNullable): string { + switch (reason) { + case 'resident_memory': + return 'resident memory'; + case 'source_unavailable': + return 'source unavailable'; + case 'artifact_store_unavailable': + return 'artifact store unavailable'; + case 'retry_backoff': + return 'retry backoff'; + } +} + +function formatBytes(bytes: number): string { + const { value, unit } = splitBytes(bytes); + return unit ? `${value} ${unit}` : value; +} + +function formatDurationSeconds(seconds: number): string { + if (seconds < 90) return `${Math.round(seconds)}s`; + if (seconds < 5_400) return `${Math.round(seconds / 60)}m`; + const hours = Math.floor(seconds / 3_600); + const minutes = Math.round((seconds % 3_600) / 60); + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +} + +function formatDurationMicros(micros: number): string { + if (micros < 1_000) return `${micros}µs`; + if (micros < 1_000_000) return `${Math.round(micros / 1_000)}ms`; + return formatDurationSeconds(micros / 1_000_000); +} + function Row({ label, children, diff --git a/dashboard/src/workspaces/endpoint-fixtures.test.ts b/dashboard/src/workspaces/endpoint-fixtures.test.ts index d31bac8990..39bc680cbb 100644 --- a/dashboard/src/workspaces/endpoint-fixtures.test.ts +++ b/dashboard/src/workspaces/endpoint-fixtures.test.ts @@ -23,7 +23,7 @@ import { z } from 'zod'; import type { ZodType } from 'zod'; import type { CostsReadModelV1 } from '../contracts/generated.ts'; -import { resolveFixture } from '../../stories/fixtures/data.ts'; +import { CODE_INDEX_FRESHNESS_FIXTURES, resolveFixture } from '../../stories/fixtures/data.ts'; import { MultiRootCapabilityV1Schema } from '../contracts/generated.ts'; import { AnyObject } from '../data/query/payload.ts'; import { @@ -923,6 +923,56 @@ describe('endpoint fixtures parse against their consuming contracts', () => { expect(worktree?.coverage).toBe('complete'); }); + it('GET /api/code-index/freshness — progress fixtures preserve active and replacement boundaries', () => { + const schema = DashboardEnvelopeV1Schema(CodeIndexFreshnessPayloadV1Schema); + const active = schema.parse(CODE_INDEX_FRESHNESS_FIXTURES.active); + const unavailableRate = schema.parse(CODE_INDEX_FRESHNESS_FIXTURES.unavailable_rate); + const readyAbsent = schema.parse(CODE_INDEX_FRESHNESS_FIXTURES.ready_absent); + const replacements = CODE_INDEX_FRESHNESS_FIXTURES.generation_replacement.map( + (fixture) => schema.parse(fixture), + ); + expect(replacements).toHaveLength(3); + const beforeReplacement = replacements[0]!; + const afterReplacement = replacements[1]!; + const staleReplacement = replacements[2]!; + + const activeProgress = active.payload.worktrees[0]?.progress; + expect(active.domain_state).toBe('loading'); + expect(activeProgress).toMatchObject({ + generation_id: 'generation.catchup.01', + progress_epoch: 1, + phase: 'bulk_commit', + completed_files: 250, + total_files: 500, + completed_lexical_bytes: 32 * 1024 * 1024, + total_lexical_bytes: 64 * 1024 * 1024, + }); + expect(activeProgress?.files_per_second).toBe(250); + expect(activeProgress?.estimated_remaining_seconds).toBe(120); + + const unavailableProgress = unavailableRate.payload.worktrees[0]?.progress; + expect(unavailableProgress?.files_per_second).toBeNull(); + expect(unavailableProgress?.lexical_bytes_per_second).toBeNull(); + expect(unavailableProgress?.estimated_remaining_seconds).toBeNull(); + expect(unavailableProgress?.blocked_reason).toBe('retry_backoff'); + + expect(readyAbsent.domain_state).toBe('ready'); + expect(readyAbsent.payload.worktrees[0]?.progress).toBeNull(); + + expect(beforeReplacement.payload.worktrees[0]?.progress?.generation_id).toBe( + 'generation.catchup.01', + ); + expect(afterReplacement.payload.worktrees[0]?.progress?.generation_id).toBe( + 'generation.catchup.02', + ); + expect(afterReplacement.payload.worktrees[0]?.progress?.progress_epoch).toBe(2); + expect(afterReplacement.payload.worktrees[0]?.progress?.completed_files).toBe(1); + expect(staleReplacement.payload.worktrees[0]?.progress).toMatchObject({ + generation_id: 'generation.catchup.01', + progress_epoch: 1, + }); + }); + it('GET /api/remote/status — Remote Brain operational envelope', () => { const env = parse( DashboardEnvelopeV1Schema(RemoteOperationalStatusPayloadV1Schema), diff --git a/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx b/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx index 34a9fc16fd..d907432613 100644 --- a/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx +++ b/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx @@ -1,6 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useScope } from '../../data/scope/store.ts'; import { ObservatoryPage } from './ObservatoryPage.tsx'; /** @@ -27,6 +28,11 @@ describe('ObservatoryPage store telemetry', () => { vi.restoreAllMocks(); }); + afterEach(() => { + useScope.getState().selectAllProjects(); + vi.useRealTimers(); + }); + it('renders every budget and growth state honestly, and merges shared-file roles', async () => { stubTelemetry(telemetryPayload()); renderObservatory(); @@ -59,6 +65,341 @@ describe('ObservatoryPage store telemetry', () => { expect(screen.getByText(/telemetry could not be determined for this store/)).toBeTruthy(); }); + it('projects the active code-index generation as a compact pipeline card', async () => { + stubTelemetry(telemetryPayload(), emptyStorageFindingsPayload(), codeIndexFreshnessEnvelope()); + renderObservatory(); + + await screen.findByText('generation.catchup.01'); + const pipeline = screen.getByLabelText('Code-index pipeline'); + const text = pipeline.textContent ?? ''; + expect(text).toContain('bulk commit · 50.0%'); + expect(text).toContain('generation.catchup.01'); + expect(text).toContain('250 / 500 files'); + expect(text).toContain('250 files/s · 16.0 MiB lexical bytes/s'); + expect(text).toContain('elapsed 2m'); + expect(text).toContain('last commit 240ms'); + }); + + it('keeps polling ready progress until the freshness envelope is ready', async () => { + vi.useFakeTimers(); + const building = codeIndexFreshnessEnvelope(); + const buildingWorktree = building.payload.worktrees[0]!; + const readyProgress = { + ...buildingWorktree.progress, + phase: 'ready', + completed_files: 500, + completed_lexical_bytes: 64 * 1024 * 1024, + estimated_remaining_seconds: 0, + }; + const transitioning = { + ...building, + domain_state: 'partial', + payload: { + ...building.payload, + worktrees: [ + { + ...buildingWorktree, + latest_generation_id: readyProgress.generation_id, + staleness_state: 'stale', + coverage: 'partial', + progress: readyProgress, + }, + ], + }, + }; + const authoritativeReady = readyCodeIndexFreshnessEnvelope(); + const ready = { + ...authoritativeReady, + payload: { + ...authoritativeReady.payload, + worktrees: [ + { + ...authoritativeReady.payload.worktrees[0]!, + latest_generation_id: readyProgress.generation_id, + progress: readyProgress, + }, + ], + }, + }; + const progressResponses = [transitioning, ready, ready]; + let progressResponse = 0; + stubTelemetry( + telemetryPayload(), + emptyStorageFindingsPayload(), + () => progressResponses[Math.min(progressResponse++, progressResponses.length - 1)]!, + ); + renderObservatory(); + + await advanceTimers(0); + expect(screen.getByText('ready · 100.0%')).toBeTruthy(); + expect(progressResponse).toBe(1); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('ready · 100.0%')).toBeTruthy(); + expect(progressResponse).toBe(2); + await advanceTimers(29_998); + expect(progressResponse).toBe(2); + await advanceTimers(1); + await advanceTimers(0); + expect(progressResponse).toBe(3); + }); + + it('accepts a later generation with a restarted epoch and rejects its delayed predecessor', async () => { + vi.useFakeTimers(); + const first = codeIndexFreshnessEnvelope(); + const firstWorktree = first.payload.worktrees[0]!; + const firstProgress = firstWorktree.progress; + const replacement = { + ...first, + payload: { + ...first.payload, + worktrees: [ + { + ...firstWorktree, + progress: { + ...firstProgress, + generation_id: 'generation.catchup.02', + daemon_incarnation: 2, + producer_incarnation: 1, + progress_epoch: 0, + last_progress_micros: SAMPLE_CURRENT_MICROS - 1, + completed_files: 1, + }, + }, + ], + }, + }; + const progressResponses = [first, replacement, first]; + let progressResponse = 0; + stubTelemetry( + telemetryPayload(), + emptyStorageFindingsPayload(), + () => progressResponses[Math.min(progressResponse++, progressResponses.length - 1)]!, + ); + renderObservatory(); + + await advanceTimers(0); + expect(screen.getByText('generation.catchup.01')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('generation.catchup.02')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('generation.catchup.01')).toBeNull(); + }); + + it('accepts a same-generation publication after restart and rejects a delayed pre-restart epoch', async () => { + vi.useFakeTimers(); + const beforeRestart = codeIndexFreshnessEnvelope(); + const beforeRestartWorktree = beforeRestart.payload.worktrees[0]!; + const beforeRestartProgress = beforeRestartWorktree.progress; + const afterRestart = { + ...beforeRestart, + payload: { + ...beforeRestart.payload, + worktrees: [ + { + ...beforeRestartWorktree, + progress: { + ...beforeRestartProgress, + daemon_incarnation: 2, + producer_incarnation: 1, + progress_epoch: 0, + last_progress_micros: SAMPLE_CURRENT_MICROS - 1, + completed_files: 1, + }, + }, + ], + }, + }; + const beforeRestartWithEpoch = { + ...beforeRestart, + payload: { + ...beforeRestart.payload, + worktrees: [ + { + ...beforeRestartWorktree, + progress: { ...beforeRestartProgress, progress_epoch: 8 }, + }, + ], + }, + }; + const progressResponses = [beforeRestartWithEpoch, afterRestart, beforeRestartWithEpoch]; + let progressResponse = 0; + stubTelemetry( + telemetryPayload(), + emptyStorageFindingsPayload(), + () => progressResponses[Math.min(progressResponse++, progressResponses.length - 1)]!, + ); + renderObservatory(); + + await advanceTimers(0); + expect(screen.getByText('250 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('1 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('250 / 500 files')).toBeNull(); + }); + + it('accepts a same-daemon remounted producer and rejects its retired predecessor', async () => { + vi.useFakeTimers(); + const retired = codeIndexFreshnessEnvelope(); + const retiredWorktree = retired.payload.worktrees[0]!; + const retiredProgress = retiredWorktree.progress; + const retiredWithEpoch = { + ...retired, + payload: { + ...retired.payload, + worktrees: [ + { + ...retiredWorktree, + progress: { ...retiredProgress, progress_epoch: 100 }, + }, + ], + }, + }; + const remounted = { + ...retired, + payload: { + ...retired.payload, + worktrees: [ + { + ...retiredWorktree, + progress: { + ...retiredProgress, + producer_incarnation: 2, + progress_epoch: 2, + completed_files: 1, + }, + }, + ], + }, + }; + const progressResponses = [retiredWithEpoch, remounted, retiredWithEpoch]; + let progressResponse = 0; + stubTelemetry( + telemetryPayload(), + emptyStorageFindingsPayload(), + () => progressResponses[Math.min(progressResponse++, progressResponses.length - 1)]!, + ); + renderObservatory(); + + await advanceTimers(0); + expect(screen.getByText('250 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('1 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('250 / 500 files')).toBeNull(); + }); + + it('removes an unmounted worktree before switching to the next scope pipeline', async () => { + vi.useFakeTimers(); + const first = codeIndexFreshnessEnvelope(); + const firstWorktree = first.payload.worktrees[0]!; + const firstProgress = firstWorktree.progress; + const aggregate = { + ...first, + payload: { + ...first.payload, + worktrees: [ + { + ...firstWorktree, + worktree_root: '/worktrees/aggregate-alpha', + progress: { ...firstProgress, generation_id: 'generation.scope.alpha' }, + }, + ], + }, + }; + const unmounted = { + ...aggregate, + payload: { ...aggregate.payload, worktrees: [] }, + }; + const projectBeta = { + ...aggregate, + scope: { project_id: 'project.beta', storage_mode: 'project', store_root: '/stores/beta' }, + payload: { + ...aggregate.payload, + worktrees: [ + { + ...firstWorktree, + worktree_root: '/worktrees/project-beta', + progress: { ...firstProgress, generation_id: 'generation.scope.beta' }, + }, + ], + }, + }; + let aggregateResponse = 0; + stubTelemetry(telemetryPayload(), emptyStorageFindingsPayload(), (url: string) => { + if (url.startsWith('/api/projects/project.beta/')) return projectBeta; + return [aggregate, unmounted][Math.min(aggregateResponse++, 1)]!; + }); + renderObservatory(); + + await advanceTimers(0); + expect(screen.getByText('generation.scope.alpha')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('generation.scope.alpha')).toBeNull(); + + act(() => useScope.getState().selectProject('project.beta', 'Project Beta', 'selected')); + await advanceTimers(0); + expect(screen.getByText('generation.scope.beta')).toBeTruthy(); + expect(screen.queryByText('generation.scope.alpha')).toBeNull(); + expect(document.querySelectorAll('[data-code-index-generation]').length).toBe(1); + }); + + it('clears an active scope pipeline before accepting a direct active scope replacement', async () => { + vi.useFakeTimers(); + const first = codeIndexFreshnessEnvelope(); + const firstWorktree = first.payload.worktrees[0]!; + const firstProgress = firstWorktree.progress; + const projectAlpha = { + ...first, + scope: { project_id: 'project.alpha', storage_mode: 'project', store_root: '/stores/alpha' }, + payload: { + ...first.payload, + worktrees: [ + { + ...firstWorktree, + worktree_root: '/worktrees/project-alpha', + progress: { ...firstProgress, generation_id: 'generation.scope.alpha' }, + }, + ], + }, + }; + const projectBeta = { + ...projectAlpha, + scope: { project_id: 'project.beta', storage_mode: 'project', store_root: '/stores/beta' }, + payload: { + ...projectAlpha.payload, + worktrees: [ + { + ...firstWorktree, + worktree_root: '/worktrees/project-beta', + progress: { ...firstProgress, generation_id: 'generation.scope.beta' }, + }, + ], + }, + }; + stubTelemetry(telemetryPayload(), emptyStorageFindingsPayload(), (url: string) => + url.startsWith('/api/projects/project.alpha/') ? projectAlpha : projectBeta, + ); + act(() => useScope.getState().selectProject('project.alpha', 'Project Alpha', 'active')); + renderObservatory(); + + await advanceTimers(0); + expect(screen.getByText('generation.scope.alpha')).toBeTruthy(); + act(() => useScope.getState().selectProject('project.beta', 'Project Beta', 'active')); + await advanceTimers(0); + expect(screen.getByText('generation.scope.beta')).toBeTruthy(); + expect(screen.queryByText('generation.scope.alpha')).toBeNull(); + expect(document.querySelectorAll('[data-code-index-generation]').length).toBe(1); + }); + it('distinguishes an unset budget from an undetermined one in the rendered state', async () => { stubTelemetry(telemetryPayload()); renderObservatory(); @@ -272,19 +613,34 @@ function renderObservatory() { ); } +async function advanceTimers(milliseconds: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds); + }); +} + function stubTelemetry( payload: unknown, findingsPayload: unknown = emptyStorageFindingsPayload(), + codeIndexFreshnessPayload: unknown | ((url: string) => unknown) = readyCodeIndexFreshnessEnvelope(), ) { vi.stubGlobal( 'fetch', vi.fn(async (input: RequestInfo | URL) => { const url = String(input); - if (url === '/api/storage/telemetry') return jsonResponse(envelope(payload)); - if (url === '/api/storage/findings') { + const route = url.replace(/^\/api\/projects\/[^/]+/, '/api'); + if (route === '/api/storage/telemetry') return jsonResponse(envelope(payload)); + if (route === '/api/storage/findings') { return jsonResponse(envelope(findingsPayload)); } - if (url === '/api/doctor/findings') { + if (route === '/api/code-index/freshness') { + return jsonResponse( + typeof codeIndexFreshnessPayload === 'function' + ? codeIndexFreshnessPayload(url) + : codeIndexFreshnessPayload, + ); + } + if (route === '/api/doctor/findings') { return jsonResponse( envelope({ family_filter: null, @@ -300,6 +656,111 @@ function stubTelemetry( ); } +function codeIndexFreshnessEnvelope() { + return { + ...readyCodeIndexFreshnessEnvelope(), + domain_state: 'loading', + coverage: { + completeness: 'unknown', + eligible: null, + examined: null, + matched: null, + excluded: null, + omitted: null, + unknown: null, + denominator: null, + unit: 'mounted_worktree', + omission_reasons: [], + }, + payload: { + worktrees: [ + { + ...codeIndexWorktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { + generation_id: 'generation.catchup.01', + daemon_incarnation: 1, + producer_incarnation: 1, + progress_epoch: 1, + sealed_source_digest: 'sha256:sealed-source-catchup', + phase: 'bulk_commit', + committed_pages: 16, + committed_chunks: 10_000, + committed_imports: 480, + committed_payload_bytes: 16 * 1024 * 1024, + completed_files: 250, + total_files: 500, + completed_lexical_bytes: 32 * 1024 * 1024, + total_lexical_bytes: 64 * 1024 * 1024, + current_batch_pages: 4, + current_batch_payload_bytes: 4 * 1024 * 1024, + elapsed_micros: 120_000_000, + last_commit_latency_micros: 240_000, + files_per_second: 250, + lexical_bytes_per_second: 16 * 1024 * 1024, + estimated_remaining_seconds: 120, + last_progress_micros: SAMPLE_CURRENT_MICROS, + blocked_reason: null, + }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }, + }; +} + +function readyCodeIndexFreshnessEnvelope() { + return { + schema_revision: 1, + scope: { project_id: 'tracedecay', storage_mode: 'project', store_root: '/store' }, + version: { entity_version: null, graph_version: null }, + time: { valid_time_micros: null, observation_time_micros: SAMPLE_CURRENT_MICROS }, + source_watermark: null, + authorization: { outcome: 'authorized' }, + coverage: { + completeness: 'complete', + eligible: 1, + examined: 1, + matched: 1, + excluded: 0, + omitted: 0, + unknown: 0, + denominator: 1, + unit: 'mounted_worktree', + omission_reasons: [], + }, + freshness: { state: 'fresh', observed_at_micros: SAMPLE_CURRENT_MICROS, watermark: null }, + domain_state: 'ready', + legal_actions: [ + { kind: 'refresh', operation: 'use-case.dashboard.code-index.freshness.refresh' }, + ], + payload: { + worktrees: [{ ...codeIndexWorktree(), progress: null }], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }, + }; +} + +function codeIndexWorktree() { + return { + worktree_root: '/fast/projects/tracedecay', + repository_id: 'repository.tracedecay', + worktree_id: 'worktree.primary', + source_reference: 'refs/heads/main', + source_revision: null, + latest_generation_id: 'generation.2f8c41ab', + snapshot_content_identity: 'sha256:9c1f4a2e7b05', + sealed_at_micros: SAMPLE_CURRENT_MICROS - 214_000_000, + last_reconcile_micros: SAMPLE_CURRENT_MICROS - 8_400_000, + staleness_state: 'fresh', + hook_hint_count: 0, + coverage: 'complete', + }; +} + function emptyStorageFindingsPayload() { return { family_filter: 'storage', diff --git a/dashboard/src/workspaces/observatory/ObservatoryPage.tsx b/dashboard/src/workspaces/observatory/ObservatoryPage.tsx index 257f47822a..70a7a0331e 100644 --- a/dashboard/src/workspaces/observatory/ObservatoryPage.tsx +++ b/dashboard/src/workspaces/observatory/ObservatoryPage.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { assertNever, StorageTelemetryPayloadV1Schema, @@ -13,9 +14,12 @@ import { type DashboardCoverageV1, type DashboardEnvelopeV1, AnalyticsDiagnosticsPayloadV1Schema, + CodeIndexFreshnessPayloadV1Schema, ObservatoryReadModelV1Schema, + type CodeIndexBuildProgressV1, + type CodeIndexFreshnessPayloadV1, } from '../../contracts/generated.ts'; -import { fetchEnvelope } from '../../data/query/envelope.ts'; +import { fetchEnvelope, type EnvelopeResult } from '../../data/query/envelope.ts'; import { useEnvelope } from '../../data/query/useEnvelope.ts'; import { useStorageFindings } from '../../data/query/storageFindings.ts'; import { scopeKey, scopedUrl, useScope } from '../../data/scope/store.ts'; @@ -23,7 +27,7 @@ import { CapacityBar } from '../../ui/ActivityColumns.tsx'; import { EnvelopeTruth } from '../../ui/EnvelopeTruth.tsx'; import { EnvelopeSection, ReadModelState } from '../../ui/ReadSection.tsx'; import { EvidenceTruthStrip } from '../../ui/EvidenceTruthStrip.tsx'; -import { formatMicrosUtc } from '../../ui/format.ts'; +import { formatCount, formatMicrosUtc } from '../../ui/format.ts'; import { OverviewCard, OverviewGrid } from '../../ui/archetypes/OverviewGrid'; import { StateChip, type DomainStateKind } from '../../ui/StateChip'; import { CanonicalObservations } from './CanonicalObservations.tsx'; @@ -56,12 +60,19 @@ import type { ObservatoryAccountingReads } from './accountingReads.ts'; * read models. A failed source never hides the other source or becomes empty. */ export function ObservatoryPage() { const scope = useScope((s) => s.scope); + const codeIndexScopeKey = scopeKey(scope); const telemetry = useQuery({ - queryKey: ['storage', 'telemetry', scopeKey(scope)], + queryKey: ['storage', 'telemetry', codeIndexScopeKey], queryFn: () => fetchEnvelope(scopedUrl(scope, '/api/storage/telemetry'), StorageTelemetryPayloadV1Schema), refetchInterval: 30_000, }); + const codeIndexFreshness = useQuery({ + queryKey: ['code-index', 'freshness', codeIndexScopeKey], + queryFn: () => + fetchEnvelope(scopedUrl(scope, '/api/code-index/freshness'), CodeIndexFreshnessPayloadV1Schema), + refetchInterval: (query) => (hasActiveCodeIndexBuild(query.state.data) ? 1_000 : 30_000), + }); // Shared with the nav rail's Doctor dot, through the module that owns the // key, the route, and the poll: one entry, one period, one contract. const findings = useStorageFindings(); @@ -132,6 +143,12 @@ export function ObservatoryPage() { + + | undefined; + pending: boolean; + scopeKey: string; +}) { + const progress = useLatestCodeIndexProgress(result, scopeKey); + if (pending) { + return ( +
+

reading code-index pipeline…

+
+ ); + } + if (result?.outcome === 'transport') { + return ( +
+ +
+ ); + } + if (progress.length === 0) { + return ( +
+

no active code-index build

+
+ ); + } + return ( +
+

Code-index pipeline

+
+ {progress.map((build) => ( + + ))} +
+
+ ); +} + +function CodeIndexBuildCard({ progress }: { progress: CodeIndexBuildProgressV1 }) { + const percentage = codeIndexProgressPercentage(progress); + const hasRate = + progress.files_per_second != null && progress.lexical_bytes_per_second != null; + return ( +
+

+ + {`${codeIndexPhaseLabel(progress.phase)} · ${percentage.toFixed(1)}%`} + +

+ +
+
generation
+
+ {progress.generation_id} +
+
files
+
+ {formatCount(progress.completed_files)} / {formatCount(progress.total_files)} files +
+
throughput
+
+ {hasRate + ? `${formatCount(progress.files_per_second)} files/s · ${formatBytes(progress.lexical_bytes_per_second)} lexical bytes/s` + : 'throughput unavailable'} +
+
elapsed
+
+ elapsed {formatDurationMicros(progress.elapsed_micros)} +
+
last commit
+
+ last commit{' '} + {progress.last_commit_latency_micros != null + ? formatDurationMicros(progress.last_commit_latency_micros) + : 'not reported'} +
+
+ {progress.blocked_reason ? ( +

+ blocked: {codeIndexBlockedReasonLabel(progress.blocked_reason)} +

+ ) : null} +
+ ); +} + +function hasActiveCodeIndexBuild( + result: EnvelopeResult | undefined, +): boolean { + return ( + result?.outcome === 'envelope' && + (result.envelope.domain_state !== 'ready' || + result.envelope.payload.worktrees.some( + (worktree) => worktree.progress != null && worktree.progress.phase !== 'ready', + )) + ); +} + +function useLatestCodeIndexProgress( + result: EnvelopeResult | undefined, + currentScopeKey: string, +): readonly CodeIndexBuildProgressV1[] { + const [latestProgress, setLatestProgress] = useState(() => ({ + scopeKey: currentScopeKey, + byWorktree: new Map(), + })); + useEffect(() => { + if (result?.outcome !== 'envelope') { + setLatestProgress((rendered) => + rendered.scopeKey === currentScopeKey + ? rendered + : { scopeKey: currentScopeKey, byWorktree: new Map() }, + ); + return; + } + setLatestProgress((rendered) => { + const current = + rendered.scopeKey === currentScopeKey + ? rendered.byWorktree + : new Map(); + const next = new Map(); + for (const worktree of result.envelope.payload.worktrees) { + const incoming = worktree.progress; + const renderedProgress = current.get(worktree.worktree_root); + if (!incoming) { + if ( + renderedProgress && + result.envelope.domain_state === 'ready' && + worktree.latest_generation_id === renderedProgress.generation_id + ) { + continue; + } + if (renderedProgress) next.set(worktree.worktree_root, renderedProgress); + } else if ( + !renderedProgress || + isCurrentOrNewerCodeIndexProgress(incoming, renderedProgress) + ) { + next.set(worktree.worktree_root, incoming); + } else { + next.set(worktree.worktree_root, renderedProgress); + } + } + return rendered.scopeKey === currentScopeKey && + sameCodeIndexProgressMap(next, rendered.byWorktree) + ? rendered + : { scopeKey: currentScopeKey, byWorktree: next }; + }); + }, [currentScopeKey, result]); + return latestProgress.scopeKey === currentScopeKey ? [...latestProgress.byWorktree.values()] : []; +} + +interface ScopedCodeIndexProgress { + scopeKey: string; + byWorktree: ReadonlyMap; +} + +function sameCodeIndexProgressMap( + left: ReadonlyMap, + right: ReadonlyMap, +): boolean { + if (left.size !== right.size) return false; + for (const [worktreeRoot, progress] of left) { + if (right.get(worktreeRoot) !== progress) return false; + } + return true; +} + +function isCurrentOrNewerCodeIndexProgress( + incoming: CodeIndexBuildProgressV1, + rendered: CodeIndexBuildProgressV1, +): boolean { + if (incoming.daemon_incarnation !== rendered.daemon_incarnation) { + return incoming.daemon_incarnation > rendered.daemon_incarnation; + } + if (incoming.producer_incarnation !== rendered.producer_incarnation) { + return incoming.producer_incarnation > rendered.producer_incarnation; + } + return incoming.progress_epoch >= rendered.progress_epoch; +} + +function codeIndexProgressPercentage(progress: CodeIndexBuildProgressV1): number { + const completed = + progress.total_lexical_bytes > 0 + ? progress.completed_lexical_bytes / progress.total_lexical_bytes + : progress.phase === 'ready' + ? 1 + : 0; + return Math.min(100, Math.max(0, completed * 100)); +} + +function codeIndexPhaseLabel(phase: CodeIndexBuildProgressV1['phase']): string { + switch (phase) { + case 'source_scan': + return 'source scan'; + case 'relational_preparation': + return 'relational preparation'; + case 'bulk_commit': + return 'bulk commit'; + case 'index_build': + return 'index build'; + case 'verification': + return 'verification'; + case 'ready': + return 'ready'; + } +} + +function codeIndexBlockedReasonLabel(reason: CodeIndexBuildProgressV1['blocked_reason']): string { + switch (reason) { + case 'resident_memory': + return 'resident memory'; + case 'source_unavailable': + return 'source unavailable'; + case 'artifact_store_unavailable': + return 'artifact store unavailable'; + case 'retry_backoff': + return 'retry backoff'; + case null: + return 'not blocked'; + } +} + +function formatDurationMicros(micros: number): string { + if (micros < 1_000) return `${micros}µs`; + if (micros < 1_000_000) return `${Math.round(micros / 1_000)}ms`; + const seconds = micros / 1_000_000; + if (seconds < 90) return `${Math.round(seconds)}s`; + if (seconds < 5_400) return `${Math.round(seconds / 60)}m`; + const hours = Math.floor(seconds / 3_600); + const minutes = Math.round((seconds % 3_600) / 60); + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +} + function TelemetryReadModel({ envelope, refreshing, diff --git a/dashboard/stories/fixtures/data.ts b/dashboard/stories/fixtures/data.ts index 59e264c5c7..e43d9ada78 100644 --- a/dashboard/stories/fixtures/data.ts +++ b/dashboard/stories/fixtures/data.ts @@ -2635,6 +2635,38 @@ const ANALYTICS_DESCRIPTOR = 'analytics-observability.v1'; const FEEDBACK_DESCRIPTOR = 'feedback-system-quality.v1'; const COST_DESCRIPTOR = 'accounting-cost.v1'; +/** + * The same freshness endpoint at four truthful mounted-registry moments. The + * static visual-audit route stays ready/absent; the remaining snapshots give + * DOM and endpoint-contract tests a complete active, rate-unavailable, and + * superseded-generation read without inventing query parameters the API does + * not accept. + */ +export const CODE_INDEX_FRESHNESS_FIXTURES = { + active: codeIndexFreshnessEnvelope(codeIndexBuildProgressFixture()), + unavailable_rate: codeIndexFreshnessEnvelope( + codeIndexBuildProgressFixture({ + files_per_second: null, + lexical_bytes_per_second: null, + estimated_remaining_seconds: null, + blocked_reason: 'retry_backoff', + }), + ), + ready_absent: codeIndexFreshnessEnvelope(null), + generation_replacement: [ + codeIndexFreshnessEnvelope(codeIndexBuildProgressFixture()), + codeIndexFreshnessEnvelope( + codeIndexBuildProgressFixture({ + generation_id: 'generation.catchup.02', + progress_epoch: 2, + completed_files: 1, + }), + ), + // A delayed pre-supersession read. Consumers must retain the newer epoch. + codeIndexFreshnessEnvelope(codeIndexBuildProgressFixture()), + ], +} as const; + /** * Exact-path fixture map. Keys are the pathname (query string stripped by the * resolver). Anything not listed resolves to the prefix table, then to {}. @@ -2698,7 +2730,7 @@ export const FIXTURES: Readonly> = { // Code-index freshness. Served against a mounted daemon scheduler, which is // the state the audit needs to shoot — the unattached case is a state chip // with no reading behind it. - '/api/code-index/freshness': codeIndexFreshnessEnvelope(), + '/api/code-index/freshness': CODE_INDEX_FRESHNESS_FIXTURES.ready_absent, '/api/remote/status': remoteOperationalStatusEnvelope(), // Work. The two mounted read routes. Unlike every other fixture here these // are wrapped in the application's `HttpJsonEnvelope` rather than @@ -3466,7 +3498,8 @@ function remoteOperationalStatusEnvelope(): Record { } /** GET /api/code-index/freshness (src/dashboard/code_index_freshness_api.rs). */ -function codeIndexFreshnessEnvelope(): Record { +function codeIndexFreshnessEnvelope(progress: Record | null): Record { + const active = progress !== null; const payload = { worktrees: [ { @@ -3475,36 +3508,68 @@ function codeIndexFreshnessEnvelope(): Record { worktree_id: 'worktree.primary', source_reference: 'refs/heads/codex/tracedecay-total-redesign-plan', source_revision: null, - latest_generation_id: 'generation.2f8c41ab', - snapshot_content_identity: 'sha256:9c1f4a2e7b05', - sealed_at_micros: nowMicros - 214_000_000, + latest_generation_id: active ? null : 'generation.2f8c41ab', + snapshot_content_identity: active ? null : 'sha256:9c1f4a2e7b05', + sealed_at_micros: active ? null : nowMicros - 214_000_000, last_reconcile_micros: nowMicros - 8_400_000, - staleness_state: 'fresh', + staleness_state: active ? 'indexing' : 'fresh', hook_hint_count: 0, coverage: 'complete', + progress, }, ], note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', }; return { - ...envelope(payload, 'ready', [ + ...envelope(payload, active ? 'loading' : 'ready', [ { kind: 'refresh', operation: 'use-case.dashboard.code-index.freshness.refresh' }, ]), coverage: { - completeness: 'complete', - eligible: 1, - examined: 1, - matched: 1, - excluded: 0, - omitted: 0, - unknown: 0, - denominator: 1, + completeness: active ? 'unknown' : 'complete', + eligible: active ? null : 1, + examined: active ? null : 1, + matched: active ? null : 1, + excluded: active ? null : 0, + omitted: active ? null : 0, + unknown: active ? null : 0, + denominator: active ? null : 1, unit: 'mounted_worktree', omission_reasons: [], }, }; } +function codeIndexBuildProgressFixture( + overrides: Partial> = {}, +): Record { + return { + generation_id: 'generation.catchup.01', + daemon_incarnation: 1, + producer_incarnation: 1, + progress_epoch: 1, + sealed_source_digest: 'sha256:sealed-source-catchup', + phase: 'bulk_commit', + committed_pages: 16, + committed_chunks: 10_000, + committed_imports: 480, + committed_payload_bytes: 16 * 1024 * 1024, + completed_files: 250, + total_files: 500, + completed_lexical_bytes: 32 * 1024 * 1024, + total_lexical_bytes: 64 * 1024 * 1024, + current_batch_pages: 4, + current_batch_payload_bytes: 4 * 1024 * 1024, + elapsed_micros: 120_000_000, + last_commit_latency_micros: 240_000, + files_per_second: 250, + lexical_bytes_per_second: 16 * 1024 * 1024, + estimated_remaining_seconds: 120, + last_progress_micros: nowMicros - 1_000_000, + blocked_reason: null, + ...overrides, + }; +} + /** GET /api/plugins/holographic/status (src/dashboard/memory_api.rs `status`). */ function memoryStatusPayload(): Record { return { diff --git a/docs/superpowers/plans/2026-08-24-code-index-text-catchup-pipeline.md b/docs/superpowers/plans/2026-08-24-code-index-text-catchup-pipeline.md new file mode 100644 index 0000000000..605475f46d --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-code-index-text-catchup-pipeline.md @@ -0,0 +1,515 @@ +# Code-Index Text Catch-Up Pipeline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the durable lexical text-artifact pipeline complete the 10,592-file cold TraceDecay corpus in at most five minutes under 8 GiB RSS while preserving exact recovery and publishing live progress. + +**Architecture:** The verified sealed source stages memory-bounded page batches, deterministic preparation runs before one ordered SQLite transaction, and the writer commits all derived rows plus per-page receipts atomically. A generation-scoped snapshot slot publishes only committed progress to the dashboard without taking the scheduler mutex. + +**Tech Stack:** Rust 2024, Tokio, rusqlite/SQLite, Hotpath 0.24, Axum/schemars, React, TanStack Query, Vitest, Criterion. + +**Spec:** `docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md` + +## Global Constraints + +- Production acceptance is at most 300 seconds and less than 8 GiB RSS on the exact isolated 10,592-file corpus. +- Per-page receipts, cursor transitions, final artifact digest, and query results remain exact. +- Cancellation or failure before commit advances neither SQLite nor the sealed-source cursor. +- Hotpath labels are static; generation/path/page/batch identities never become labels. +- Progress reports committed boundaries only and never blocks on the scheduler mutex. +- No timeout, resident-memory ceiling, durability setting, or correctness assertion is weakened. +- Root is the sole shared Cargo, dashboard codegen, and contract-generation coordinator. + +--- + +### Task 1: Atomic sealed-source page batches + +**Files:** +- Modify: `crates/tracedecay-code-index/src/production/lexical_page_source.rs` + +**Interfaces:** +- Consumes: `VerifiedSealedLexicalPageV1`, `VerifiedSealedLexicalCursorV1`, and the existing `stage_next_page` transition. +- Produces: `VerifiedSealedLexicalPageBatchBoundsV1`, `VerifiedSealedLexicalPageBatchReadV1`, and `VerifiedSealedLexicalPageSourceV1::next_page_batch_if`. + +- [ ] **Step 1: Write the callback-refusal regression** + +Add a real source test that records the initial cursor, asks for four pages, +returns a literal `"reject-batch"` from the callback, and then reads one page +through `next_page`. Assert that the page ordinal and cumulative digest equal +the hand-recorded first page rather than the fifth page. + +```rust +let before = source.cursor().clone(); +let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new(4, 32 * 1024 * 1024) + .expect("valid batch bounds"); +let refused = source.next_page_batch_if(&control, bounds, |_| { + Err::<(), _>("reject-batch") +}); +assert!(matches!(refused, Ok(Err("reject-batch")))); +assert_eq!(source.cursor(), &before); +assert_eq!(next_page.page_ordinal(), 0); +``` + +- [ ] **Step 2: Run the exact test and observe RED** + +Run: + +```bash +scripts/require-exact-test.sh cargo test -p tracedecay-code-index --lib --locked \ + production::lexical_page_source::tests::batch_rejection_restores_the_exact_source_cursor -- --exact +``` + +Expected: compilation fails because `next_page_batch_if` does not exist. + +- [ ] **Step 3: Implement bounded batch staging** + +Add the typed batch read and API: + +```rust +pub enum VerifiedSealedLexicalPageBatchReadV1 { + Pages(Vec), + Complete(VerifiedSealedLexicalSourceReceiptV1), +} + +pub struct VerifiedSealedLexicalPageBatchBoundsV1 { + maximum_pages: usize, + maximum_retained_bytes: usize, +} + +pub fn next_page_batch_if( + &mut self, + control: &dyn CodeIndexExecutionControlV1, + bounds: VerifiedSealedLexicalPageBatchBoundsV1, + admit: impl FnOnce(&[VerifiedSealedLexicalPageV1]) -> Result<(), E>, +) -> Result, CodeIndexProductionErrorV1>; +``` + +Require nonzero bounds. Save the exact initial cursor, stage contiguous pages, +stop before the next page would exceed either bound, call `admit` once, and +assign the final working cursor only after success. On callback error, restore +the saved cursor. Completion after staged pages is returned on the next call so +the callback never receives an empty batch. + +- [ ] **Step 4: Add boundary and completion tests** + +Cover one-page count bound, byte-bound stop, exact ordinal order, completion +after the last accepted batch, and cancellation during staging. Derive expected +page ordinals and digests from literal first/last fixtures rather than the new +batch method. + +- [ ] **Step 5: Run the source slice and commit** + +```bash +cargo test -p tracedecay-code-index --lib --locked production::lexical_page_source::tests:: +git add crates/tracedecay-code-index/src/production/lexical_page_source.rs +git commit -m 'perf(index): batch sealed lexical pages' +``` + +### Task 2: Atomic multi-page artifact append with the mutation fence preserved + +**Files:** +- Modify: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs` +- Modify: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs` +- Create: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs` +- Modify: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs` +- Test: `crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs` + +**Interfaces:** +- Consumes: ordered `&[VerifiedSealedLexicalPageV1]` from Task 1. +- Produces: bounded `PreparedCodeLexicalArtifactPageV1`, `prepare_pages`, `append_prepared_pages`, and `CodeLexicalArtifactBuilderV1::append_pages`; one-page append delegates to the same path. + +- [ ] **Step 1: Write the rollback RED** + +Stage two valid pages and one page with a foreign generation in one batch. Assert +`append_pages` returns `Contract` and `progress()` remains exactly the initial +zero progress. Then append the two valid pages and assert page count two. + +```rust +let before = builder.progress().expect("initial progress"); +assert!(matches!( + builder.append_pages(&[page0.clone(), page1.clone(), foreign], &control), + Err(CodeLexicalArtifactErrorV1::Contract(_)) +)); +assert_eq!(builder.progress().expect("rolled back progress"), before); +``` + +- [ ] **Step 2: Run RED exactly** + +```bash +scripts/require-exact-test.sh cargo test -p tracedecay-query --test search_quality_suite --locked \ + candidate_producers::disk_artifact_batch_is_atomic_and_replay_exact -- --exact +``` + +Expected: compilation fails because `append_pages` does not exist. + +- [ ] **Step 3: Implement batch memory admission** + +Compute the batch charge before opening SQLite: + +```rust +needed = fixed_ledger_charge_bytes + + pages.iter().map(VerifiedSealedLexicalPageV1::retained_owned_bytes).sum::() + + prepared.iter().map(PreparedCodeLexicalArtifactPageV1::retained_owned_bytes).sum::() + + active_workers + .iter() + .map(page_preparation_scratch_bytes) + .sum::() + + task_overhead; +``` + +Use checked arithmetic, independent prepared-row/estimated-write ceilings, and +return a typed batch-too-large refusal before mutation. +Validate contiguous ordinals, transitions, and cumulative digests for every +page against a working progress/cursor. A typed pre-SQLite batch-too-large +refusal lets the scheduler shrink the batch and retry from the unchanged source +cursor. + +- [ ] **Step 4: Prepare deterministic relational pages outside SQLite** + +Move projection, JSON encoding, integrity hashing, frequency aggregation, exact +postings, n-grams, imports, vocabulary, statistics deltas, and the source-page +receipt into `prepared.rs`. The prepared type owns values only; it cannot open +SQLite or publish progress. It records a checked retained-memory charge. + +```rust +pub fn prepare_pages( + &self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result, CodeLexicalArtifactErrorV1>; +``` + +- [ ] **Step 5: Implement one ordered SQLite transaction** + +Add: + +```rust +pub fn append_pages( + &mut self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result; + +pub fn append_prepared_pages( + &mut self, + pages: &[PreparedCodeLexicalArtifactPageV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result; +``` + +`append_pages` prepares serially as the compatibility path and delegates to +`append_prepared_pages`. Open one transaction after all admission checks. Append imports, derived rows, +and one source receipt per page in ordinal order. Check cancellation before each +page and immediately before commit. Read progress once after commit. Implement +`append_page` as `self.append_pages(std::slice::from_ref(page), control)`. + +- [ ] **Step 6: Preserve and prove the ingestion-time mutation fence** + +Keep the existing epoch triggers active from staging creation through +finalization. Add a pre-finalization self-attesting corruption test that changes +derived rows or postings and rewrites the public integrity digest while keeping +row counts stable; finalization must still return typed corruption. Do not bump +the format solely for batching. + +- [ ] **Step 7: Add bounded static instrumentation** + +Wrap the fixed boundaries only: + +```rust +hotpath::measure_block!("query.artifact.batch.imports", { + write_prepared_imports(&transaction, pages, control)? +}); +hotpath::measure_block!("query.artifact.batch.rows", { + write_prepared_rows(&transaction, pages, control)? +}); +hotpath::measure_block!("query.artifact.batch.receipts", { + write_prepared_receipts(&transaction, pages)? +}); +hotpath::measure_block!("query.artifact.batch.commit", { + transaction.commit().map_err(sqlite_error)? +}); +``` + +Increment static gauges for committed batches/pages/chunks and rollback count. +No metric label includes an ordinal or identity. + +- [ ] **Step 8: Prove preparation, mutation, and recovery contracts** + +Prepare the same literal page through serial and ordered-parallel callers, +append each to a fresh artifact, and assert equal receipts, digests, and +representative query rows. + +Run the new batch test plus: + +```bash +scripts/require-exact-test.sh cargo test -p tracedecay-query --test search_quality_suite --locked \ + candidate_producers::disk_artifact_finalization_refuses_inter_wake_mutation -- --exact +scripts/require-exact-test.sh cargo test -p tracedecay-query --test search_quality_suite --locked \ + candidate_producers::disk_artifact_first_finalize_rejects_self_attesting_derived_mutation -- --exact +``` + +- [ ] **Step 9: Commit the builder slice** + +```bash +git add crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs \ + crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs \ + crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs \ + crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs \ + crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +git commit -m 'perf(query): bulk append lexical artifact pages' +``` + +### Task 3: Scheduler batching and nonblocking progress authority + +**Files:** +- Modify: `src/daemon/code_index_scheduler.rs` +- Modify: `src/daemon/code_index_scheduler/registry.rs` +- Test: `src/daemon/code_index_scheduler/tests.rs` + +**Interfaces:** +- Consumes: `next_page_batch_if`, `prepare_pages`, and `append_prepared_pages` from Tasks 1-2. +- Produces: `CodeIndexBuildProgressV1` snapshots and an O(1) mounted progress slot. + +- [ ] **Step 1: Write the uncommitted-progress RED** + +Use a real mounted scheduler with a control that cancels inside batch append. +Assert the dashboard snapshot remains at the prior committed page and source +cursor. A second non-cancelled advance must publish the batch once. + +- [ ] **Step 2: Run RED exactly** + +```bash +scripts/require-exact-test.sh cargo test -p tracedecay --lib --locked \ + daemon::code_index_scheduler::tests::dashboard_progress_advances_only_after_durable_batch_commit -- --exact +``` + +- [ ] **Step 3: Add the mounted snapshot slot** + +Store `Arc>>` beside +`serving_generation` in `MountedCodeIndexWorktreeV1`. Clone that slot in +`dashboard_freshness` before entering `spawn_blocking`; never acquire the +scheduler mutex to read it. + +- [ ] **Step 4: Drive batches through the scheduler** + +Replace the page-at-a-time loop with `next_page_batch_if` using a maximum of 16 pages +and 32 MiB sealed payload per batch, while still clamping total wake operations +to 64. Prepare pages through the existing canonical bounded CPU authority, keep +the result in page-ordinal order, then call `builder.append_prepared_pages` on +the single writer. On the typed pre-SQLite batch-too-large refusal, halve the +page bound and retry from the unchanged source cursor; do not retry any other +error. Publish a snapshot only after the source and writer both succeed. + +- [ ] **Step 5: Compute truthful rate and ETA** + +Keep two committed samples `(Instant, completed_lexical_bytes)`. Publish rate +only when elapsed and byte delta are positive. Compute ETA as remaining exact +sealed lexical span divided by that rate. Clear samples on generation change; +reconstruct counts/cursor from builder progress after reopen but leave rate and +ETA absent until a second process-local sample exists. + +- [ ] **Step 6: Prove nonblocking and supersession behavior** + +Add tests that hold the scheduler mutex while `dashboard_freshness` completes, +barrier an old generation immediately before publication, supersede it, and +prove an epoch CAS prevents the old worker from overwriting or clearing the new +snapshot. Reopen an intermediate staging artifact with exact committed counts +but no fabricated rate. + +- [ ] **Step 7: Commit runtime integration** + +```bash +git add src/daemon/code_index_scheduler.rs \ + src/daemon/code_index_scheduler/registry.rs \ + src/daemon/code_index_scheduler/tests.rs +git commit -m 'feat(index): publish live catch-up progress' +``` + +### Task 4: Freshness contract and Code progress UI + +**Files:** +- Modify: `crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs` +- Modify: `dashboard/stories/fixtures/data.ts` +- Modify: `dashboard/src/workspaces/code/IndexFreshness.tsx` +- Test: `dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx` +- Modify: `dashboard/src/workspaces/observatory/ObservatoryPage.tsx` +- Test: `dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx` +- Test: `dashboard/src/workspaces/endpoint-fixtures.test.ts` +- Generate: `dashboard/src/contracts/generated.ts` + +**Interfaces:** +- Consumes: runtime `CodeIndexBuildProgressV1` from Task 3. +- Produces: optional `build_progress` in `CodeIndexWorktreeFreshnessV1`. + +- [ ] **Step 1: Add failing active-progress fixtures** + +Use literal active progress values: 250/1,000 files, 400/1,600 lexical bytes, +40 bytes/s, ETA 30 seconds. Assert accessible progress value 25%, visible +counts/rate/ETA, and one-second active polling. Add a second fixture with null +rate/ETA and assert `measuring rate` rather than zero. + +- [ ] **Step 2: Run focused RED** + +```bash +cd dashboard +npm test -- src/workspaces/code/IndexFreshness.dom.test.tsx \ + src/workspaces/observatory/ObservatoryPage.dom.test.tsx \ + src/workspaces/endpoint-fixtures.test.ts +``` + +- [ ] **Step 3: Add the Rust wire type** + +Define a schemars/serde type whose integer counters are lossless in the existing +JSON contract and add `build_progress: Option` to the +worktree payload. Include generation, phase, committed/total file and lexical +byte bounds, page/chunk/import/payload counts, optional rate/ETA, commit latency, +last progress micros, and optional blocked reason. + +- [ ] **Step 4: Render active progress without inference** + +Render a native `` from exact lexical bytes when total is nonzero, +with file percentage as supporting text. Show rate/ETA only when supplied. +Change TanStack `refetchInterval` to a function returning 1,000 ms only when a +worktree has active progress and 30,000 ms otherwise. + +Render the same component and wire authority in Observatory as a compact +pipeline card; do not introduce a second fetch or a parallel progress model. + +- [ ] **Step 5: Regenerate and verify contracts** + +```bash +cd dashboard +npm run contracts:generate +npm run contracts:check +npm run typecheck +npm test -- src/workspaces/code/IndexFreshness.dom.test.tsx \ + src/workspaces/observatory/ObservatoryPage.dom.test.tsx \ + src/workspaces/endpoint-fixtures.test.ts +``` + +- [ ] **Step 6: Commit the dashboard slice** + +```bash +git add crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs \ + dashboard/stories/fixtures/data.ts dashboard/src/contracts/generated.ts \ + dashboard/src/workspaces/code/IndexFreshness.tsx \ + dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx \ + dashboard/src/workspaces/observatory/ObservatoryPage.tsx \ + dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx \ + dashboard/src/workspaces/endpoint-fixtures.test.ts +git commit -m 'feat(dashboard): show live index catch-up progress' +``` + +### Task 5: Deterministic ingestion benchmark + +**Files:** +- Create: `crates/tracedecay-query/benches/code_lexical_artifact_catchup.rs` +- Modify: `crates/tracedecay-query/Cargo.toml` + +**Interfaces:** +- Consumes: public one-page and batch append APIs. +- Produces: Criterion comparison with digest/receipt equivalence assertions. + +- [ ] **Step 1: Build one deterministic page corpus** + +Construct the same verified pages once per benchmark input outside the timed +closure. Each timed iteration creates a fresh private artifact and uses either +one-page append or batches of 16. + +- [ ] **Step 2: Assert equivalence outside timing** + +Before registering benchmarks, build both artifacts, finalize them, and assert +literal equality for page/chunk/payload counts, final artifact digest, and +representative exact/lexical query results. + +- [ ] **Step 3: Register and run the benchmark** + +```bash +cargo bench -p tracedecay-query --bench code_lexical_artifact_catchup --profile perf +``` + +Record median time and throughput for both paths; do not add a hard-coded CI +speed threshold. + +- [ ] **Step 4: Commit the harness** + +```bash +git add crates/tracedecay-query/Cargo.toml \ + crates/tracedecay-query/benches/code_lexical_artifact_catchup.rs +git commit -m 'bench(query): measure lexical artifact batching' +``` + +### Task 6: Measure the retained serving-index cost + +**Files:** +- No production schema edits in this slice. + +**Interfaces:** +- Consumes: Task 5 benchmark and Hotpath evidence. +- Produces: a measured decision while retaining the existing six native indexes + and query plans unchanged. + +- [ ] **Step 1: Measure the remaining online-index cost** + +Run the Task 5 benchmark and a Hotpath feature-on pass after Tasks 1-5. Proceed +only if online index maintenance remains a material owner or the production +journey exceeds 300 seconds. + +- [ ] **Step 2: Preserve the current contract** + +SQLite cannot incrementally populate a native index through shadow tables and +atomically rename it into place. Keep the current native index inventory and +all `INDEXED BY` query plans unchanged. If online maintenance remains a material +owner after batching, write a separate design that either accepts a measured +monolithic engine operation or deliberately changes the lookup-table contract; +do not smuggle either choice into this slice. + +### Task 7: Production acceptance and PR + +**Files:** +- No production edits during measurement. + +**Interfaces:** +- Consumes: exact committed branch head and measurement from Tasks 1-6. +- Produces: reproducible cold/resume evidence and PR into the integration branch. + +- [ ] **Step 1: Run static and focused gates** + +```bash +cargo fmt --all -- --check +git diff --check +cargo check -p tracedecay-code-index -p tracedecay-query -p tracedecay-dashboard-api --all-features --locked +cd dashboard && npm run contracts:check && npm run typecheck +``` + +- [ ] **Step 2: Build the production feature profile** + +Resolve the repository's canonical production release features and build with +the `perf` profile. Record the exact binary SHA-256 and source commit. + +- [ ] **Step 3: Run cold, resume, and settled journeys** + +Use the isolated 10,592-file fixture. Record wall time, one-second RSS, CPU, +I/O, Hotpath timing, committed progress snapshots, generation ID, artifact +digest, and expected search hits. Stop once mid-build, restart, and prove the +committed cursor resumes without replay. + +- [ ] **Step 4: Enforce acceptance** + +Do not declare success unless text readiness is at most 300 seconds and peak RSS +is below 8 GiB. If it misses, identify the residual measured phase and return to +the corresponding task rather than raising the deadline. + +- [ ] **Step 5: Push and open the PR** + +```bash +git push -u origin codex/code-index-catchup-pipeline +gh pr create \ + --base codex/tracedecay-total-redesign-plan-reopened \ + --head codex/code-index-catchup-pipeline \ + --title 'perf(index): accelerate durable text catch-up' \ + --body-file /tmp/code-index-catchup-pr-body.md +``` + +The PR body includes the cold/resume evidence, exact gates, residual risks, and +the fact that Loom remains intentionally session-only. diff --git a/docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md b/docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md new file mode 100644 index 0000000000..a9b2fc0ca5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md @@ -0,0 +1,351 @@ +# Code-Index Text Catch-Up Pipeline Design + +**Date:** 2026-08-24 +**Status:** Approved for implementation +**Scope:** Durable lexical text-artifact construction and its live dashboard projection + +## Problem + +TraceDecay can seal a large code generation without exceeding the process memory +guard, but the subsequent durable text-artifact catch-up is too slow and mostly +invisible. On the 10,592-file TraceDecay corpus, the production journey remained +in `partial_refresh_in_progress` for more than 25 minutes and had processed only +about 17% of files when it was stopped. + +The live baseline was: + +- 34 durable source pages per 30 seconds; +- about 4,230 chunks and 7.7 MiB of sealed payload per 30 seconds; +- 0.64 CPU on a 96-CPU host; +- about 139 MiB/s of writes; +- 7.70 GiB peak RSS, below the 8 GiB safety guard; +- no numeric progress, rate, or ETA in Code or Observatory. + +Hotpath and source evidence agree on the dominant boundary. SQLite repository +execution is the largest timing/allocation owner, followed by page projection +and ordered chunk mapping. The current builder makes that boundary unnecessarily +expensive: + +1. every 128-chunk source page is its own `DELETE`-journal transaction; +2. six secondary indexes are maintained row-by-row during bulk ingestion; +3. 33 row triggers update the same singleton `content_epoch` row for every + insert, update, or delete across eleven mutable tables; +4. JSON projection, token/posting derivation, SQLite insertion, receipt writing, + and commit all happen serially inside the writer transaction; +5. the authenticated build cursor remains private to the staging database, so + the dashboard receives only coarse `indexing`/`refreshing` state. + +At the observed rate, catch-up would take roughly an hour. Reaching five minutes +requires an order-of-magnitude change in transaction and write amplification; +more extraction workers or a larger timeout cannot fix it. + +## Goals + +1. Complete the exact 10,592-file cold text-artifact journey in **five minutes + or less** on the same host and production profile. +2. Keep peak process RSS below **8 GiB** during the journey. +3. Preserve byte-exact query results, source receipts, generation identity, and + final artifact digest semantics. +4. Preserve bounded cancellation, crash recovery, replay idempotence, and + fail-closed corruption handling. +5. Publish truthful live progress, throughput, and estimated remaining time to + Code and Observatory without blocking the scheduler. +6. Add Hotpath evidence at the ownership boundaries needed to distinguish page + preparation, SQLite mutation, commit/journal time, index construction, and + final verification. + +## Non-goals + +- Native graph activation and graph memory planning are not part of this slice. +- Initial source capture, language extraction, and sealed-generation encoding + are already separate measured phases and are not redesigned here. +- Loom remains a session-timeline surface. Code-index construction progress + belongs in Code and Observatory. +- No timeout, resident-memory ceiling, correctness assertion, or durability + policy is weakened to obtain the target. +- The design does not introduce a second query format, shadow progress store, + or test-only production authority. + +## Chosen architecture + +The pipeline becomes a bounded producer/consumer flow with one canonical +durable writer: + +```text +verified sealed source + -> bounded page batch + -> parallel deterministic page preparation + -> one ordered SQLite bulk transaction + -> durable per-page receipts + source cursor + -> existing bounded finalization + -> existing two-pass verification and atomic publication +``` + +The sealed source, SQLite artifact, per-page receipts, and final receipt remain +the only durable authorities. Parallel work produces deterministic values; it +never publishes progress or owns durability. + +### 1. Bounded page batches + +The verified sealed source gains a batch admission operation. It stages ordered +pages against a working cursor and advances its real cursor only after the +builder accepts the whole batch. If preparation, SQLite mutation, cancellation, +or commit fails, the source restores the exact pre-batch cursor and yields the +same pages on retry. + +Batch admission is byte-bound, not merely count-bound: + +- the retained bytes of every staged page are summed; +- the retained capacities of every prepared page are summed while the borrowed + sealed-page batch remains live; +- preparation scratch is charged for every concurrently active worker, along + with bounded task overhead; +- prepared row count and estimated SQLite write bytes have independent ceilings + so source bytes cannot hide unbounded relational expansion or commit latency; +- source decode-window bytes, builder metadata, and SQLite cache authority stay + charged by the existing resident-memory reservation; +- batch size shrinks rather than exceeding the 256 MiB builder ceiling. + +The initial target is up to 16 pages or 32 MiB of sealed payload, whichever +bound is reached first. These are work-unit ceilings, not tuning promises; the +memory ledger remains the admission authority. + +### 2. Deterministic preparation outside the writer transaction + +Each page is converted into a prepared relational page before opening the +SQLite transaction. Preparation owns: + +- projected artifact rows and their JSON bytes; +- document integrity digests; +- term postings and frequency deltas; +- exact and n-gram postings; +- import evidence and integrity digests; +- vocabulary and field-statistic deltas; +- the exact source-page receipt row. + +Preparation is pure with respect to persistent state. Pages may be prepared on +the canonical bounded CPU pool, but the ordered result vector is keyed by page +ordinal and is admitted only when contiguous from the builder's durable cursor. +Preparation uses structured concurrency: cancellation is observed by every +worker and every admitted worker is joined before the call and its memory +reservation return. +No dynamic task labels or unbounded per-page metric identities are created. + +### 3. One ordered bulk transaction per batch + +The writer consumes prepared pages in ordinal order inside one transaction. It +uses cached statements for the whole batch and writes every page's source +receipt in the same transaction as its derived rows. The transaction commits +only after the final page receipt is written and cancellation is checked. Once +`COMMIT` succeeds there is no fallible cancellation check before the final +committed receipt is acknowledged. + +The existing one-page API becomes a one-element wrapper over the batch API so +all callers share one correctness path. + +A successful commit advances progress to the final receipt cursor. A failed +commit advances neither SQLite progress nor the sealed-source cursor. Recovery +continues from the last committed page exactly as it does today. + +### 4. Preserve the ingestion-time mutation fence + +The existing mutation-detection triggers remain active from staging-database +creation through finalization. They are currently the independent authority +that catches an external connection changing derived rows and rewriting the +public integrity digest before finalization. Moving them to finalization would +allow self-attesting corruption because the final digest passes read the same +mutable staging database. + +Batching amortizes transaction and journal costs without weakening this fence. +A future trigger optimization requires a separate durable mutation authority +active from database creation and a pre-finalization corruption journey that +covers derived rows and postings; it is not part of this slice. + +### 5. Keep the native index inventory unchanged + +All current native SQLite indexes remain online during ingestion in this +slice. SQLite cannot incrementally populate a native index through shadow +tables and atomically rename it into place, and a monolithic `CREATE INDEX` +would violate bounded cancellation without changing the artifact contract. +Deferred index construction therefore remains a separately measured design +decision rather than a claimed part of this optimization. + +### 6. Live progress authority + +Every committed batch publishes one immutable in-memory progress snapshot tied +to the exact generation, sealed-source identity, and mount epoch. The mounted +registry owns an `Arc` snapshot slot separate from the scheduler mutex, so +dashboard reads do not block behind a long reconcile. Every publish uses a +generation/mount-epoch compare-and-swap; a delayed superseded worker can neither +overwrite nor clear the current generation's snapshot. + +The snapshot contains: + +- phase: source scan, relational preparation, bulk commit, index build, + verification, or ready; +- generation and sealed-source digest; +- committed pages, chunks, imports, and payload bytes; +- completed and total file ordinals; +- completed and total sealed lexical byte span; +- current batch size and last commit duration; +- monotonic elapsed time for the current process; +- rolling throughput over authenticated completed sealed lexical bytes, with + file rate retained as secondary evidence; +- estimated remaining seconds when at least two samples establish a positive + rate; +- last-progress timestamp and an optional typed blocked reason. + +Progress is exact at committed batch boundaries. Phase-entry snapshots may +change the phase while retaining the previous durable counters, but staged or +prepared work is never reported as durable. Percentage and ETA use the same +authenticated completed sealed-byte numerator and total sealed-byte span; file +rate is display-only. ETA is absent until two positive process-local, +same-generation samples exist. + +The slot is cleared or replaced on generation supersession and survives no +process restart. On restart, the first snapshot is reconstructed from the +durable source-page cursor before new work begins. + +### 7. Dashboard projection + +`GET /api/code-index/freshness` gains an optional generation-scoped progress +object. Existing readiness and authorization states remain unchanged. + +Code renders: + +- phase and exact percentage; +- files, pages, chunks, and payload committed; +- current throughput; +- estimated remaining time; +- last progress age and typed blocked reason. + +Observatory renders the same authority as a compact pipeline card with phase +durations and commit throughput. Both surfaces refresh every second while a +build is active and return to the existing slower cadence when ready. Rendering +does not advance the build, acquire the scheduler mutex, or infer missing data. + +## Hotpath instrumentation + +Instrumentation is static and bounded per the pinned Hotpath 0.24 contract. +No generation, path, page ordinal, or batch ordinal appears in a metric label. + +Required timing spans: + +- `query.artifact.batch.source` +- `query.artifact.batch.prepare` +- `query.artifact.batch.sqlite` +- `query.artifact.batch.imports` +- `query.artifact.batch.rows` +- `query.artifact.batch.receipts` +- `query.artifact.batch.commit` +- `query.artifact.index.build` +- `query.artifact.finalization.verify` +- `dashboard.code_index.progress` + +Required gauges/counters: + +- active prepared bytes and pages; +- committed pages, rows, and payload bytes; +- SQLite commits and rollback count; +- rows written by table family; +- index rows built and current index phase; +- latest commit latency; +- progress publication count and age. + +Allocation profiling remains a separate diagnostic run. The five-minute and +8-GiB requirements are established by wall-clock and OS RSS evidence, not by +Hotpath totals. + +## Failure and cancellation semantics + +- Cancellation before commit rolls back the transaction and restores the + source cursor. +- Cancellation after commit returns the committed durable progress; retry + starts at the next page. +- Every parallel preparation task observes cancellation and is joined before + the reservation is released. +- A crash during a batch leaves SQLite at the prior transaction boundary. +- A corrupt or non-contiguous receipt refuses resume; it is never skipped. +- A generation change cancels preparation, drops uncommitted pages, and cannot + publish progress under the new generation identity. +- A dashboard reader sees either the previous complete snapshot or the next + complete snapshot; it never observes a partially updated struct. + +## Alternatives considered + +### Tune SQLite pragmas only + +Rejected. The live run is dominated by deliberate row and transaction +amplification. Larger caches, `WAL`, or weaker synchronization do not remove +millions of hot-row updates or online index maintenance, and changing durability +would violate the artifact contract. + +### One SQLite database per worker followed by merge + +Rejected for this slice. It can parallelize writes but creates a second merge +authority, duplicates schema and receipt logic, and makes crash recovery more +complex. The canonical ordered writer is sufficient once preparation and +transaction amplification are separated. + +### Build the serving artifact during initial extraction + +Deferred. It could eliminate the sealed-source replay, but it couples generation +publication to one query format and would make query-artifact failure block a +valid sealed generation. The current separation is valuable; this design makes +the replay bounded and fast instead. + +## Verification + +### Correctness tests + +- one-page behavior remains byte- and receipt-equivalent; +- multi-page commit publishes every page atomically and contiguously; +- cancellation during preparation and during SQLite mutation leaves the source + and builder at the pre-batch cursor; +- cancellation after commit resumes at the next exact page; +- a subprocess killed after DML/receipt-before-commit reopens at the prior + cursor, while a kill after commit-before-source-ack replays idempotently from + the committed final cursor; +- inter-wake mutation remains a typed corruption under the always-active epoch + fence; +- pre-finalization self-attesting derived-row and posting corruption remains a + typed refusal; +- parallel preparation produces the same ordered relational values as serial + preparation; +- dashboard progress is generation/epoch-scoped, monotonic, nonblocking, and + absent when no mounted build exists; a delayed superseded publisher cannot + overwrite or clear the current slot. + +### Performance tests + +1. A focused builder benchmark compares one-page and bounded-batch ingestion on + the same deterministic page set and records rows, commits, bytes written, + and wall time. +2. A maximum-token/ngram-expansion fixture proves active preparation plus all + retained prepared pages remain within the single 256 MiB reservation and + the prepared-write ceiling bounds commit latency. +3. The production 10,592-file isolated journey is rerun from a cold store with + native graph disabled and the same resident-memory authority. + +The production acceptance is driven by one benchmark manifest that pins the +sealed source digest and generation, expected artifact receipt/digest, binary +features and worker count, cold-artifact/OS-cache policy, exact timer events, +RSS sampler, expected queries, restart injection point, and Hotpath identity +set. The timer starts when canonical reconcile admits the sealed source and +stops only after the same generation owns a query-ready text artifact. + +The production acceptance is: + +- text artifact ready in at most 300 seconds; +- peak RSS below 8 GiB; +- exact generation ID and final artifact digest match the baseline source; +- exact/lexical search returns the expected symbols; +- restart from a deliberately stopped intermediate batch resumes without + replaying committed pages; +- Code and Observatory show monotonic progress at one-second cadence; +- the runtime-reported Hotpath identity set stays constant as corpus size grows, + with feature-on and feature-off builds both passing. + +If the full journey remains above five minutes, the branch must report the +measured residual phase and continue optimizing it. A partial speedup is not +accepted as completion of this design. diff --git a/src/bin/tracedecay-search-eval-direct.rs b/src/bin/tracedecay-search-eval-direct.rs index 7922883dd7..0cb4f260d9 100644 --- a/src/bin/tracedecay-search-eval-direct.rs +++ b/src/bin/tracedecay-search-eval-direct.rs @@ -67,7 +67,7 @@ enum Command { #[arg(long, default_value = ".")] project_root: PathBuf, #[arg(long)] - candidate: PathBuf, + profile: String, }, /// Run the native evaluator in the owning daemon and write only its /// independently validated qualification evidence. @@ -143,8 +143,8 @@ fn main() -> ExitCode { }, Command::EvaluateAndPublish { project_root, - candidate, - } => evaluate_and_publish(project_root, candidate), + profile, + } => evaluate_and_publish(project_root, profile), Command::QualifyNative { project_root, candidate, @@ -163,11 +163,7 @@ fn validate_requested_workload( ) } -fn evaluate_and_publish(project_root: PathBuf, candidate_path: PathBuf) -> ExitCode { - let candidate = match read_semantic_candidate(&candidate_path) { - Ok(candidate) => candidate, - Err(error) => return invalid("evaluate_and_publish", error), - }; +fn evaluate_and_publish(project_root: PathBuf, evaluated_profile_id: String) -> ExitCode { let runtime = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -187,7 +183,7 @@ fn evaluate_and_publish(project_root: PathBuf, candidate_path: PathBuf) -> ExitC }; match client .evaluate_and_publish_semantic_profile_until( - candidate, + &evaluated_profile_id, SEMANTIC_EVALUATION_ISOLATED_DISPATCH_DEADLINE_MICROS, ) .await @@ -426,6 +422,37 @@ mod tests { )); } + #[test] + fn evaluate_and_publish_accepts_only_a_daemon_owned_profile_selection() { + let cli = Cli::try_parse_from([ + "tracedecay-search-eval", + "evaluate-and-publish", + "--project-root", + "project", + "--profile", + "hybrid-conservative", + ]) + .expect("evaluate-and-publish profile arguments parse"); + + assert!(matches!( + cli.command, + Command::EvaluateAndPublish { + project_root, + profile, + } if project_root == *"project" && profile == "hybrid-conservative" + )); + assert!( + Cli::try_parse_from([ + "tracedecay-search-eval", + "evaluate-and-publish", + "--candidate", + "caller-authored.json", + ]) + .is_err(), + "the publishing route must not accept caller-authored candidate JSON" + ); + } + #[test] fn corrupt_native_qualification_bytes_are_rejected_without_writing() { let output = tempfile::tempdir() diff --git a/src/daemon/bootstrap.rs b/src/daemon/bootstrap.rs index 5bf6fb94f1..87dab34ba4 100644 --- a/src/daemon/bootstrap.rs +++ b/src/daemon/bootstrap.rs @@ -110,7 +110,8 @@ pub async fn run_foreground( let store_administration = StoreAdministration::default().with_profile_identity(authority.profile_identity().clone()); let project_open_gates = Arc::new(tokio::sync::Mutex::new(ProjectOpenGates::default())); - let invocation = DaemonInvocationState::default(); + let invocation = + DaemonInvocationState::with_progress_producer_incarnation(authority.record().epoch); store_administration .configure_codex_preparation_resources( invocation.code_index_schedulers.process_resident_memory(), @@ -497,6 +498,7 @@ async fn run_foreground_unix( )?; let http_application_registry = http_application::DaemonHttpApplicationRegistry::default(); let engine = DaemonEngine::default() + .with_progress_producer_incarnation(authority.record().epoch) .with_profile_identity(authority.profile_identity().clone()) .with_http_application_registry(http_application_registry.clone()); engine diff --git a/src/daemon/code_index_executor.rs b/src/daemon/code_index_executor.rs index 05757ffb97..b9a9763aca 100644 --- a/src/daemon/code_index_executor.rs +++ b/src/daemon/code_index_executor.rs @@ -299,6 +299,140 @@ pub(super) fn code_index_search_display_binding( Ok((display, provenance)) } +pub(super) fn code_index_text_search_display_binding( + latest: &code_index_scheduler::LatestCodeTextGenerationV1, + request: &tracedecay_domain::RetrievalRequest, + candidate: &tracedecay_domain::RankedCandidate, +) -> std::result::Result< + ( + code_search::CodeIndexSearchDisplayV1, + tracedecay_domain::OccurrenceProvenance, + ), + tracedecay_query::retrieval::hydrate::HydrationUnavailableV1, +> { + use tracedecay_query::retrieval::hydrate::HydrationUnavailableV1; + + let metadata = latest.metadata(); + let manifest = metadata.manifest(); + let snapshot = metadata.snapshot(); + if request.scope.privacy_domain != manifest.privacy_domain + || request.scope.root.repository != snapshot.repository + || request.scope.root.worktree != snapshot.worktree + || request.scope.root.reference != snapshot.reference + || request.snapshot.freshness_digest.as_str() != manifest.snapshot_digest.as_str() + || request.snapshot.captured_at != manifest.seal.sealed_at + { + return Err(HydrationUnavailableV1::Stale); + } + + let source_prefix = format!("code-chunk:{}:", manifest.generation_id.as_str()); + let (provenance, chunk_id) = candidate + .candidate + .occurrences + .iter() + .find_map(|provenance| { + let chunk_id = provenance + .source_occurrence_id + .as_str() + .strip_prefix(&source_prefix)?; + (provenance.repository_id.as_ref() == Some(&request.scope.root.repository) + && provenance.source_namespace == provenance.freshness.source_namespace + && provenance.freshness.compatibility + == tracedecay_domain::FreshnessCompatibilityV1::Current + && provenance.source_namespace.as_str() == "ns.code.daemon") + .then_some((provenance.clone(), chunk_id)) + }) + .ok_or(HydrationUnavailableV1::Invalid)?; + let chunk_id = tracedecay_domain::CodeSearchChunkId::new(chunk_id.to_owned()) + .map_err(|_| HydrationUnavailableV1::Invalid)?; + let occurrence = latest + .artifact_occurrence_by_chunk(&chunk_id) + .map_err(|_| HydrationUnavailableV1::AuthorityUnavailable)?; + if occurrence.generation != manifest.generation_id + || !snapshot.files.iter().any(|file| { + file.file_occurrence_id == occurrence.file + && file.logical_path == occurrence.logical_path + && file.disposition == tracedecay_domain::SnapshotFileDispositionV1::Present + }) + { + return Err(HydrationUnavailableV1::Stale); + } + + let anchor = candidate.candidate.anchor_id.as_str(); + if let Some(symbol) = anchor.strip_prefix("code-symbol:") { + if occurrence.symbol.as_ref().map(|value| value.as_str()) != Some(symbol) { + return Err(HydrationUnavailableV1::Invalid); + } + } else if anchor.strip_prefix("code-chunk:") != Some(chunk_id.as_str()) { + return Err(HydrationUnavailableV1::Invalid); + } + + let display = match occurrence.symbol { + Some(_) => code_search::CodeIndexSearchDisplayV1 { + name: occurrence + .simple_name + .ok_or(HydrationUnavailableV1::Invalid)?, + qualified_name: occurrence + .qualified_name + .ok_or(HydrationUnavailableV1::Invalid)?, + kind: occurrence.kind.ok_or(HydrationUnavailableV1::Invalid)?, + path: occurrence.logical_path, + }, + None => { + if occurrence.simple_name.is_some() + || occurrence.qualified_name.is_some() + || occurrence.kind.is_some() + { + return Err(HydrationUnavailableV1::Invalid); + } + let name = occurrence + .logical_path + .rsplit('/') + .next() + .unwrap_or(occurrence.logical_path.as_str()) + .to_owned(); + code_search::CodeIndexSearchDisplayV1 { + name, + qualified_name: occurrence.logical_path.clone(), + kind: "file".to_owned(), + path: occurrence.logical_path, + } + } + }; + Ok((display, provenance)) +} + +enum CodeIndexSearchDisplaySourceV1 { + Text(code_index_scheduler::LatestCodeTextGenerationV1), + Complete { + latest: code_index_scheduler::LatestCompleteCodeIndexV1, + paths: CodeIndexDisplayPathIndexV1, + }, +} + +impl CodeIndexSearchDisplaySourceV1 { + fn binding( + &self, + request: &tracedecay_domain::RetrievalRequest, + candidate: &tracedecay_domain::RankedCandidate, + ) -> std::result::Result< + ( + code_search::CodeIndexSearchDisplayV1, + tracedecay_domain::OccurrenceProvenance, + ), + tracedecay_query::retrieval::hydrate::HydrationUnavailableV1, + > { + match self { + Self::Text(latest) => { + code_index_text_search_display_binding(latest, request, candidate) + } + Self::Complete { latest, paths } => { + code_index_search_display_binding(latest.generation(), paths, request, candidate) + } + } + } +} + fn code_index_symbol_display( symbol: &crate::code_index::lineage::LineageSymbolRecordV1, display_paths: &CodeIndexDisplayPathIndexV1, @@ -791,21 +925,28 @@ pub(super) fn code_index_search_executor( None, ), }; - let latest = match generation_for_hydration( - &schedulers, - &terminal_scope, - &executed.query.generation, - control.deadline.clone(), - control.cancellation.clone(), - ) - .await - { - Ok(latest) => latest, - Err(outcome) => return outcome, - }; - let display_paths = - match CodeIndexDisplayPathIndexV1::for_generation(latest.generation()) { - Ok(display_paths) => display_paths, + let display_source = if let Some(text) = schedulers + .latest_text_serving_for_scope(&terminal_scope) + .await + .filter(|text| { + text.metadata().manifest().generation_id == executed.query.generation + }) { + CodeIndexSearchDisplaySourceV1::Text(text) + } else { + let latest = match generation_for_hydration( + &schedulers, + &terminal_scope, + &executed.query.generation, + control.deadline.clone(), + control.cancellation.clone(), + ) + .await + { + Ok(latest) => latest, + Err(outcome) => return outcome, + }; + let paths = match CodeIndexDisplayPathIndexV1::for_generation(latest.generation()) { + Ok(paths) => paths, Err(_) => { return code_index_search_unavailable_for_generation( Some(executed.query.generation.as_str().to_owned()), @@ -814,6 +955,8 @@ pub(super) fn code_index_search_executor( ); } }; + CodeIndexSearchDisplaySourceV1::Complete { latest, paths } + }; let mut hydration_request = executed.query.sanitized.request().clone(); let hydration_budget = code_index_search_hydration_budget( accepted_semantic_budget, @@ -896,12 +1039,7 @@ pub(super) fn code_index_search_executor( _permit: &tracedecay_query::retrieval::hydrate::HydrationWorkPermitV1| { use tracedecay_query::retrieval::hydrate::HydrationPreflightOutcomeV1; - match code_index_search_display_binding( - latest.generation(), - &display_paths, - request, - candidate, - ) + match display_source.binding(request, candidate) .and_then(|(display, _)| code_index_search_display_bytes(&display)) { Ok(estimated_bytes) => { @@ -916,12 +1054,7 @@ pub(super) fn code_index_search_executor( _permit: &tracedecay_query::retrieval::hydrate::HydrationWorkPermitV1| { use tracedecay_query::retrieval::hydrate::HydrationReadOutcomeV1; - let (display, provenance) = match code_index_search_display_binding( - latest.generation(), - &display_paths, - request, - candidate, - ) { + let (display, provenance) = match display_source.binding(request, candidate) { Ok(binding) => binding, Err(reason) => return HydrationReadOutcomeV1::Unavailable(reason), }; diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index e6015538db..370828f855 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -38,14 +38,17 @@ use tracedecay_private_fs::{ }; use tracedecay_runtime_core::resident_memory::{ DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, ProcessResidentMemoryV1, - ResidentMemoryAdjustmentFailureV1, ResidentMemoryAdmissionFailureV1, - ResidentMemoryComponentIdV1, ResidentMemoryKeyV1, ResidentMemoryReservationV1, + ResidentMemoryAdmissionFailureV1, ResidentMemoryComponentIdV1, ResidentMemoryKeyV1, + ResidentMemoryReservationV1, }; use tracedecay_usecases::code_index::{ DaemonCodeIndexControlV1, ProductionCodeIndexOwnerV1, open_production_code_index_owner_v1, }; use self::freshness_witness::RestoreFreshnessWitnessV1; +use crate::dashboard::code_index_freshness_api::{ + CodeIndexBuildBlockedReasonV1, CodeIndexBuildPhaseV1, CodeIndexBuildProgressV1, +}; use crate::{ code_index::{ @@ -60,8 +63,9 @@ use crate::{ CodeIndexIgnoredSourceAdmissionV1, CodeIndexInputErrorV1, CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, - SharedPhysicalCodeArtifactPoolV1, VerifiedSealedLexicalPageReadV1, - VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalSourceReceiptV1, + SharedPhysicalCodeArtifactPoolV1, VerifiedSealedLexicalPageBatchBoundsV1, + VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageSourceV1, + VerifiedSealedLexicalSourceReceiptV1, VerifiedSealedTextGenerationMetadataV1, }, projection::{ ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, @@ -78,7 +82,9 @@ use crate::{ lexical::{ CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeExactLexicalArtifactReaderV1, - CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, CodeLexicalArtifactReaderV1, + CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, + CodeLexicalArtifactFinalizationPhaseV1, CodeLexicalArtifactFinalizationStepV1, + CodeLexicalArtifactOccurrenceV1, CodeLexicalArtifactReaderV1, CodeLexicalProjectionMetadataV1, LexicalLane, LexicalLaneEvidence, LexicalLaneRequest, LexicalLaneRetriever, }, @@ -110,6 +116,8 @@ const DURABLE_GENERATION_IO_CHUNK_BYTES_V1: usize = 64 * 1024; /// text artifact. One page is one bounded unit of background build progress. const TEXT_ARTIFACT_PAGE_CHUNKS_V1: usize = 128; const TEXT_ARTIFACT_PAGE_BYTES_V1: usize = 4 * 1024 * 1024; +const TEXT_ARTIFACT_BATCH_PAGES_V1: usize = 32; +const TEXT_ARTIFACT_BATCH_BYTES_V1: usize = 64 * 1024 * 1024; /// One synchronous activation advances only this many page/finalization /// operations. Larger caller hints are clamped so work accounting cannot /// overflow and every expensive loop retains cancellation checkpoints. @@ -448,6 +456,21 @@ struct PublicationPointerMemoV1 { pointer: DurablePublicationPointerV1, } +#[derive(Clone)] +struct UndecodedActivePublicationExpectationV1 { + generation_id: String, + generation_file: String, + state_digest: String, +} + +impl UndecodedActivePublicationExpectationV1 { + fn matches(&self, pointer: &DurablePublicationPointerV1) -> bool { + self.generation_id == pointer.generation_id + && self.generation_file == pointer.generation_file + && self.state_digest == pointer.state_digest + } +} + #[derive(Clone)] struct DaemonCodeIndexPublicationStoreV1 { cache: Arc, @@ -458,6 +481,7 @@ struct DaemonCodeIndexPublicationStoreV1 { expected_sanitizer_revision: SanitizerRevision, disposition: CodeIndexPublicationDispositionV1, pointer_memo: Arc>>, + undecoded_active_expectation: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -509,9 +533,20 @@ impl DaemonCodeIndexPublicationStoreV1 { expected_sanitizer_revision, disposition: CodeIndexPublicationDispositionV1::Active, pointer_memo: Arc::new(Mutex::new(None)), + undecoded_active_expectation: None, }) } + fn for_undecoded_active_rebuild(&self, pointer: &DurablePublicationPointerV1) -> Self { + let mut publication = self.clone(); + publication.undecoded_active_expectation = Some(UndecodedActivePublicationExpectationV1 { + generation_id: pointer.generation_id.clone(), + generation_file: pointer.generation_file.clone(), + state_digest: pointer.state_digest.clone(), + }); + publication + } + fn retained_history(&self) -> Self { let mut retained = self.clone(); retained.disposition = CodeIndexPublicationDispositionV1::RetainedHistory; @@ -1155,11 +1190,15 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { &self, _scope: &CodeIndexGenerationScopeV1, ) -> Result, CodeIndexPublicationStoreErrorV1> { + if self.undecoded_active_expectation.is_some() { + return Ok(None); + } Ok(self .load_active_shared()? .map(|generation| generation.as_ref().clone())) } + #[hotpath::measure(label = "code_index.generation.publish")] fn publish_atomically( &mut self, _scope: &CodeIndexGenerationScopeV1, @@ -1172,14 +1211,34 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { .ok_or_else(|| Self::unavailable("active code-generation pointer has no store root"))?; let _store_lock = acquire_code_generation_store_lock(store_root).map_err(Self::unavailable)?; - let _ = self.load_active_shared()?; + let prior_pointer = if let Some(expected) = self.undecoded_active_expectation.as_ref() { + if expected_active_generation.is_some() { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + let pointer = self + .read_publication_pointer()? + .ok_or(CodeIndexPublicationStoreErrorV1::CompareAndSwap)?; + if !expected.matches(&pointer) { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + Some(pointer) + } else { + let _ = self.load_active_shared()?; + self.read_publication_pointer()? + }; let state = self.cache.lock_state()?; - if state + let cached_active = state .active .as_ref() - .map(|current| ¤t.manifest().generation_id) - != expected_active_generation - { + .map(|current| ¤t.manifest().generation_id); + let cache_matches = self.undecoded_active_expectation.as_ref().map_or( + cached_active == expected_active_generation, + |expected| { + cached_active + .is_none_or(|generation| generation.as_str() == expected.generation_id.as_str()) + }, + ); + if !cache_matches { return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); } if self.disposition == CodeIndexPublicationDispositionV1::RetainedHistory @@ -1216,13 +1275,21 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { Err(error) => return Err(Self::unavailable(error)), } let mut temporary = TemporaryGenerationFileV1::new(temporary_path); - let generation_size = Self::write_sealed_durable(&temporary.path, &generation)?; + let generation_size = hotpath::measure_block!( + "code_index.generation.publish.seal_fsync", + Self::write_sealed_durable(&temporary.path, &generation) + )?; if generation_size > MAX_DURABLE_GENERATION_INDEX_BYTES_V1 { return Err(Self::unavailable( "sealed code generation exceeds the durable history byte bound", )); } - let state_digest = Self::state_digest_file(&temporary.path)?; + let state_digest = hotpath::measure_block!( + "code_index.generation.publish.state_digest", + Self::state_digest_file(&temporary.path) + )?; + #[cfg(feature = "hotpath")] + hotpath::gauge!("code_index.generation.publish.digest_bytes").set(generation_size); let generation_file = format!( "generation-{}.json", state_digest @@ -1232,7 +1299,11 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { let generation_path = self.generations_root.join(&generation_file); match generation_path.symlink_metadata() { Ok(_) => { - if !Self::files_equal(&generation_path, &temporary.path)? { + let equal = hotpath::measure_block!( + "code_index.generation.publish.dedupe_compare", + Self::files_equal(&generation_path, &temporary.path) + )?; + if !equal { return Err(Self::unavailable( "immutable code-generation path contains different bytes", )); @@ -1248,7 +1319,6 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { Err(error) => return Err(Self::unavailable(error)), } - let prior_pointer = self.read_publication_pointer()?; let exact_git_evidence = self.exact_git_evidence(&generation)?; let mut generation_index = prior_pointer .as_ref() @@ -1339,34 +1409,43 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { if temporary.exists() { std::fs::remove_file(&temporary).map_err(Self::unavailable)?; } - Self::write_durable(&temporary, &bytes)?; - std::fs::rename(&temporary, &self.active_path).map_err(Self::unavailable)?; - Self::sync_directory( - self.active_path - .parent() - .ok_or_else(|| Self::unavailable("active pointer has no parent directory"))?, - )?; - self.remember_publication_pointer(&pointer, &bytes); + hotpath::measure_block!("code_index.generation.publish.pointer_commit", { + Self::write_durable(&temporary, &bytes)?; + std::fs::rename(&temporary, &self.active_path).map_err(Self::unavailable)?; + Self::sync_directory( + self.active_path + .parent() + .ok_or_else(|| Self::unavailable("active pointer has no parent directory"))?, + )?; + self.remember_publication_pointer(&pointer, &bytes); + Ok::<(), CodeIndexPublicationStoreErrorV1>(()) + })?; let mut state = self.cache.lock_state()?; - if state - .active - .as_ref() - .map(|current| ¤t.manifest().generation_id) - != expected_active_generation - { - return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + if self.undecoded_active_expectation.is_none() { + let cached_active = state + .active + .as_ref() + .map(|current| ¤t.manifest().generation_id); + if cached_active != expected_active_generation { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } } let generation_id = generation.manifest().generation_id.clone(); state.forget(&generation_id); match self.disposition { CodeIndexPublicationDispositionV1::Active => { - self.active_encoded_bytes - .store(generation_size, Ordering::Release); // The published generation is already decoded and validated in // memory. Bumping the epoch retires any decode that started // against the prior pointer so it cannot install over this one. state.active_epoch = state.active_epoch.wrapping_add(1); - state.active = Some(generation); + if self.undecoded_active_expectation.is_some() { + self.active_encoded_bytes.store(0, Ordering::Release); + state.active = None; + } else { + self.active_encoded_bytes + .store(generation_size, Ordering::Release); + state.active = Some(generation); + } } CodeIndexPublicationDispositionV1::RetainedHistory => { state.decoded.push_back(generation); @@ -1464,6 +1543,61 @@ impl PendingHintsV1 { fn take(&mut self) -> Self { std::mem::take(self) } + + fn restore(&mut self, pending: Self) { + if self.overflow { + return; + } + if pending.overflow { + self.overflow(); + return; + } + for path in pending.paths { + self.path(path); + if self.overflow { + break; + } + } + } +} + +/// A drained view of the canonical pending-hint authority. Until committed, +/// every early return, typed failure, cancellation, or unwind merges the exact +/// drained paths back with hints that arrived during the reconcile pass. +struct DrainedPendingHintsV1 { + authority: Arc>, + pending: Option, +} + +impl DrainedPendingHintsV1 { + fn new(authority: Arc>, pending: PendingHintsV1) -> Self { + Self { + authority, + pending: Some(pending), + } + } + + fn overflow(&self) -> bool { + self.pending + .as_ref() + .is_some_and(|pending| pending.overflow) + } + + fn commit(mut self) { + self.pending = None; + } +} + +impl Drop for DrainedPendingHintsV1 { + fn drop(&mut self) { + let Some(pending) = self.pending.take() else { + return; + }; + self.authority + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .restore(pending); + } } /// One candidate path's capture result, produced independently per file so @@ -1473,9 +1607,9 @@ struct CapturedCandidateV1 { captured: CodeIndexCapturedFileV1, receipt_id: SanitizationReceiptId, retained: Arc<[u8]>, - /// Charges both live source representations (interned `Arc` and production - /// input `Vec`) before the candidate can join a snapshot. The build copy's - /// half is released after production completes. + /// Charges the canonical source allocation before the candidate can join a + /// snapshot. Production borrows this allocation until its bounded intake + /// materialization, rather than retaining a second snapshot-wide copy. retained_reservation: Option, } @@ -1489,8 +1623,8 @@ struct CapturedSnapshotV1 { /// snapshot's bytes so identical content in sibling worktrees can reuse /// them (physical sharing without identity aliasing). retained_bytes: Vec>, - /// Pointer-paired resident charges for `retained_bytes` plus their live - /// production-input copies. Empty sources need no nonzero reservation. + /// Resident charges for `retained_bytes`. Empty sources need no nonzero + /// reservation. retained_reservations: Vec, } @@ -1533,28 +1667,280 @@ type GenerationServingCachesV1 = ( Arc>, Arc>>, Arc, + GenerationTextControlV1, + Arc>, + u64, Arc>, ); +pub(super) type CodeIndexBuildProgressSlotV1 = Arc>; + +/// Cancellation authority for derivations of one immutable sealed generation. +/// +/// Worktree freshness epochs deliberately do not participate: a hook wake can +/// make the source worktree newer, but it cannot invalidate bytes already +/// sealed under a content-addressed generation. Only daemon shutdown, owner +/// retirement, or replacement by another serving generation retires this +/// control. +#[derive(Clone)] +struct GenerationTextControlV1 { + execution: DaemonCodeIndexControlV1, + retirement_epoch: Arc, + #[cfg(feature = "hotpath")] + shutting_down: Arc, +} + +#[cfg(feature = "hotpath")] +#[derive(Clone, Copy)] +enum GenerationTextCancellationSourceV1 { + Shutdown, + Superseded, +} + +impl GenerationTextControlV1 { + fn new(shutting_down: Arc) -> Self { + let retirement_epoch = Arc::new(AtomicU64::new(0)); + let execution = DaemonCodeIndexControlV1::new( + Arc::clone(&retirement_epoch), + Arc::clone(&shutting_down), + ); + Self { + execution, + retirement_epoch, + #[cfg(feature = "hotpath")] + shutting_down, + } + } + + fn retire(&self) { + DaemonCodeIndexControlV1::advance(&self.retirement_epoch); + } + + #[cfg(feature = "hotpath")] + fn cancellation_source(&self) -> Option { + if self.shutting_down.load(Ordering::Acquire) { + Some(GenerationTextCancellationSourceV1::Shutdown) + } else if self.execution.is_cancelled() { + Some(GenerationTextCancellationSourceV1::Superseded) + } else { + None + } + } +} + +impl CodeIndexExecutionControlV1 for GenerationTextControlV1 { + fn is_cancelled(&self) -> bool { + self.execution.is_cancelled() + } + + fn is_deadline_exceeded(&self) -> bool { + self.execution.is_deadline_exceeded() + } +} + +#[derive(Default)] +pub(super) struct CodeIndexBuildProgressSlotStateV1 { + generation_id: Option, + owner_epoch: u64, + progress_epoch: u64, + snapshot: Option>, +} + +impl CodeIndexBuildProgressSlotStateV1 { + fn replace_generation(&mut self, generation_id: CodeGenerationId) -> u64 { + self.owner_epoch = self.owner_epoch.saturating_add(1).max(1); + self.progress_epoch = self.progress_epoch.saturating_add(1).max(1); + self.generation_id = Some(generation_id); + self.snapshot = None; + self.owner_epoch + } + + fn publish( + &mut self, + generation_id: &CodeGenerationId, + owner_epoch: u64, + mut snapshot: CodeIndexBuildProgressV1, + ) -> bool { + if self.generation_id.as_ref() != Some(generation_id) || self.owner_epoch != owner_epoch { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.progress.rejected_stale_total").inc(1u64); + return false; + } + self.progress_epoch = self.progress_epoch.saturating_add(1).max(1); + snapshot.progress_epoch = self.progress_epoch; + #[cfg(feature = "hotpath")] + let published_phase = snapshot.phase; + self.snapshot = Some(Arc::new(snapshot)); + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.progress.publication_total").inc(1u64); + match published_phase { + CodeIndexBuildPhaseV1::SourceScan => { + hotpath::gauge!("query.artifact.progress.phase.source_scan_total").inc(1u64); + } + CodeIndexBuildPhaseV1::RelationalPreparation => { + hotpath::gauge!("query.artifact.progress.phase.preparation_total").inc(1u64); + } + CodeIndexBuildPhaseV1::BulkCommit => { + hotpath::gauge!("query.artifact.progress.phase.bulk_commit_total").inc(1u64); + } + CodeIndexBuildPhaseV1::IndexBuild => { + hotpath::gauge!("query.artifact.progress.phase.index_build_total").inc(1u64); + } + CodeIndexBuildPhaseV1::Verification => { + hotpath::gauge!("query.artifact.progress.phase.verification_total").inc(1u64); + } + CodeIndexBuildPhaseV1::Ready => { + hotpath::gauge!("query.artifact.progress.phase.ready_total").inc(1u64); + } + } + } + true + } + + pub(super) fn snapshot(&self) -> Option> { + self.snapshot.as_ref().map(Arc::clone) + } +} + +#[derive(Clone, Copy)] +struct CodeIndexCommittedProgressSampleV1 { + observed_at: Instant, + completed_files: u64, + completed_lexical_bytes: u64, +} + +struct CodeIndexBuildProgressStateV1 { + started_at: Instant, + committed_samples: VecDeque, +} + +impl CodeIndexBuildProgressStateV1 { + fn new() -> Self { + Self { + started_at: Instant::now(), + committed_samples: VecDeque::with_capacity(2), + } + } + + fn observe_committed(&mut self, sample: CodeIndexCommittedProgressSampleV1) { + if self.committed_samples.back().is_some_and(|previous| { + previous.completed_files == sample.completed_files + && previous.completed_lexical_bytes == sample.completed_lexical_bytes + }) { + return; + } + if self.committed_samples.len() == 2 { + self.committed_samples.pop_front(); + } + self.committed_samples.push_back(sample); + } + + fn elapsed_micros(&self) -> u64 { + u64::try_from(self.started_at.elapsed().as_micros()).unwrap_or(u64::MAX) + } + + fn rates_and_eta(&self, total_lexical_bytes: u64) -> (Option, Option, Option) { + let Some(previous) = self.committed_samples.front() else { + return (None, None, None); + }; + let Some(current) = self.committed_samples.back() else { + return (None, None, None); + }; + if self.committed_samples.len() < 2 || current.observed_at <= previous.observed_at { + return (None, None, None); + } + let elapsed_seconds = current + .observed_at + .duration_since(previous.observed_at) + .as_secs_f64(); + if elapsed_seconds <= 0.0 { + return (None, None, None); + } + let files_per_second = current + .completed_files + .checked_sub(previous.completed_files) + .filter(|delta| *delta > 0) + .map(|delta| delta as f64 / elapsed_seconds); + let lexical_bytes_per_second = current + .completed_lexical_bytes + .checked_sub(previous.completed_lexical_bytes) + .filter(|delta| *delta > 0) + .map(|delta| delta as f64 / elapsed_seconds); + let estimated_remaining_seconds = lexical_bytes_per_second.and_then(|lexical_rate| { + let remaining = total_lexical_bytes.saturating_sub(current.completed_lexical_bytes); + let estimate = (remaining as f64 / lexical_rate).ceil(); + (estimate.is_finite() && estimate >= 0.0 && estimate <= u64::MAX as f64) + .then_some(estimate as u64) + }); + ( + files_per_second, + lexical_bytes_per_second, + estimated_remaining_seconds, + ) + } +} + #[derive(Clone)] pub(in crate::daemon) struct LatestCompleteCodeIndexV1 { generation: Arc, - query_owners: Arc>>, + text: LatestCodeTextGenerationV1, record_index: Arc>, + graph_activation: Arc>, +} + +#[derive(Clone)] +pub(in crate::daemon) struct LatestCodeTextGenerationV1 { + metadata: Arc, + query_owners: Arc>>, /// Generation-owned partial durable text-artifact build. Only the /// background scheduler advances it; /// foreground queries observe typed warming until the immutable owners /// are installed. text_projection_build: Arc>>, text_projection_failed: Arc, + text_control: GenerationTextControlV1, + text_progress_state: Arc>, + text_progress_slot: CodeIndexBuildProgressSlotV1, + text_progress_owner_epoch: u64, + /// Durable daemon-authority epoch, shared by all scheduler owners created + /// during one daemon invocation. + text_progress_daemon_incarnation: u64, + /// Registry-minted scheduler-owner epoch. It orders progress across + /// scheduler owner replacements within one daemon incarnation. + text_progress_producer_incarnation: u64, /// The durable text-artifact store for this generation's store root. text_artifact_store: DaemonCodeTextArtifactStoreV1, - /// Exact scheduler authorities for shutdown and superseding source epochs. - /// Each bounded pass captures the then-current epoch before touching the - /// durable source or artifact. - text_control_epoch: Arc, - text_control_shutdown: Arc, - graph_activation: Arc>, + /// A cold graph-off bind authenticates the sealed source once and hands + /// that same reader to the artifact build. The full generation is never + /// decoded merely to discover text metadata or source layout. + preopened_source: Arc>>>, + publication_binding: Option>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct DurableActiveSealedGenerationBindingV1 { + generation_id: CodeGenerationId, + generation_file: String, + state_digest: ManifestDigest, +} + +impl DurableActiveSealedGenerationBindingV1 { + fn matches(&self, pointer: Option<&DurablePublicationPointerV1>) -> bool { + pointer.is_some_and(|pointer| { + pointer.generation_id == self.generation_id.as_str() + && pointer.generation_file == self.generation_file + && pointer.state_digest == self.state_digest.as_str() + }) + } +} + +impl std::ops::Deref for LatestCompleteCodeIndexV1 { + type Target = LatestCodeTextGenerationV1; + + fn deref(&self) -> &Self::Target { + &self.text + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1575,6 +1961,7 @@ pub(super) struct ProductionCodeIndexQueryOwnersV1 { CodeExactLexicalArtifactReaderV1, >, lexical: LexicalLane, + hydration: CodeLexicalArtifactReaderV1, /// Holds the complete advertised reader ceiling in the process resident- /// memory authority while these owners serve. _reader_reservation: Arc, @@ -1594,15 +1981,79 @@ impl ProductionCodeIndexQueryOwnersV1 { CodeExactLexicalArtifactReaderV1, >, lexical: LexicalLane, + hydration: CodeLexicalArtifactReaderV1, reader_reservation: ResidentMemoryReservationV1, ) -> Self { Self { exact, lexical, + hydration, _reader_reservation: Arc::new(reader_reservation), } } + fn occurrence_by_binding( + &self, + binding: &tracedecay_query::retrieval::ports::CodeCandidateBindingV1, + ) -> Result< + tracedecay_query::retrieval::NativeCodeOccurrenceV1, + tracedecay_query::retrieval::QueryExecutionContractErrorV1, + > { + self.hydration + .occurrence_by_binding(binding) + .map_err(|_| { + tracedecay_query::retrieval::QueryExecutionContractErrorV1::RecordUnavailable + })? + .map( + |occurrence| tracedecay_query::retrieval::NativeCodeOccurrenceV1 { + file: occurrence.file, + symbol: occurrence.symbol, + chunk: Some(occurrence.chunk), + path: occurrence.logical_path, + span: occurrence.source_span, + }, + ) + .ok_or(tracedecay_query::retrieval::QueryExecutionContractErrorV1::RecordUnavailable) + } + + fn occurrence_by_chunk( + &self, + chunk: &tracedecay_domain::CodeSearchChunkId, + ) -> Result< + tracedecay_query::retrieval::NativeCodeOccurrenceV1, + tracedecay_query::retrieval::QueryExecutionContractErrorV1, + > { + self.hydration + .occurrence_by_chunk(chunk) + .map_err(|_| { + tracedecay_query::retrieval::QueryExecutionContractErrorV1::RecordUnavailable + })? + .map( + |occurrence| tracedecay_query::retrieval::NativeCodeOccurrenceV1 { + file: occurrence.file, + symbol: occurrence.symbol, + chunk: Some(occurrence.chunk), + path: occurrence.logical_path, + span: occurrence.source_span, + }, + ) + .ok_or(tracedecay_query::retrieval::QueryExecutionContractErrorV1::RecordUnavailable) + } + + fn artifact_occurrence_by_chunk( + &self, + chunk: &tracedecay_domain::CodeSearchChunkId, + ) -> Result { + self.hydration + .occurrence_by_chunk(chunk) + .map_err(|error| RetrievalPortError::AuthorityUnavailable(error.to_string()))? + .ok_or_else(|| { + RetrievalPortError::AuthorityUnavailable( + "lexical artifact row is unavailable".to_owned(), + ) + }) + } + pub fn retrieve_lexical( &self, request: &LexicalLaneRequest<'_>, @@ -1640,7 +2091,8 @@ fn map_text_artifact_error(error: CodeLexicalArtifactErrorV1) -> RetrievalPortEr CodeLexicalArtifactErrorV1::Incompatible(_) => RetrievalPortError::IncompatibleProjection, CodeLexicalArtifactErrorV1::Contract(detail) => RetrievalPortError::Contract(detail), CodeLexicalArtifactErrorV1::Corrupt(detail) => RetrievalPortError::Contract(detail), - CodeLexicalArtifactErrorV1::Unreserved(_) => RetrievalPortError::BudgetExceeded, + CodeLexicalArtifactErrorV1::Unreserved(_) + | CodeLexicalArtifactErrorV1::BatchTooLarge { .. } => RetrievalPortError::BudgetExceeded, CodeLexicalArtifactErrorV1::Io(detail) | CodeLexicalArtifactErrorV1::Missing(detail) => { RetrievalPortError::AuthorityUnavailable(detail) } @@ -1930,80 +2382,128 @@ impl DaemonCodeTextArtifactStoreV1 { .map_err(map_sealed_page_source_error) } + fn open_sealed_source_with_progress( + &self, + identity: &DurableSealedCodeGenerationIdentityV1, + control: &dyn CodeIndexExecutionControlV1, + progress: F, + ) -> Result, RetrievalPortError> + where + F: FnMut(u64, u64), + { + DaemonCodeIndexPublicationStoreV1::validate_generation_file(&identity.locator) + .map_err(|error| RetrievalPortError::Contract(error.to_string()))?; + let path = self.publication.generations_root.join(&identity.locator); + let metadata = path.symlink_metadata().map_err(text_artifact_unavailable)?; + if !metadata.file_type().is_file() || metadata.len() != identity.size_bytes { + return Err(RetrievalPortError::Contract( + "durable sealed lexical source identity is corrupt".to_owned(), + )); + } + let file = File::open(path).map_err(text_artifact_unavailable)?; + VerifiedSealedLexicalPageSourceV1::open_content_addressed_with_progress( + file, + identity.size_bytes, + identity.digest.clone(), + TEXT_ARTIFACT_PAGE_CHUNKS_V1, + TEXT_ARTIFACT_PAGE_BYTES_V1, + control, + progress, + ) + .map_err(map_sealed_page_source_error) + } + /// Durably publish one finalized staging artifact: content-address it, /// move it into the artifacts root, fsync the directory, and attach the /// descriptor to the sealed generation entry under the store lock. fn publish( &self, staging_path: &Path, - generation: &CodeIndexPublishedGenerationV1, + generation_id: &CodeGenerationId, sealed_identity: &DurableSealedCodeGenerationIdentityV1, control: &dyn CodeIndexExecutionControlV1, ) -> Result { - let artifacts_root = code_text_artifacts_root(&self.store_root); - ensure_private_text_artifacts_root(&artifacts_root)?; - // Publication and artifact retention share this canonical store lock. - // Hold it from the first staging observation until pointer attachment - // is durable so retention cannot unlink a newly visible artifact from - // a plan made before the descriptor was attached. - let lock = acquire_code_generation_store_lock(&self.store_root) - .map_err(text_artifact_unavailable)?; - let (artifact_hex, artifact_size_bytes) = - sha256_private_file_hex_and_size(staging_path, control)?; - let descriptor = DurableCodeTextArtifactDescriptorV1 { - generation_id: generation.manifest().generation_id.clone(), - artifact_file: format!("text-artifact-{artifact_hex}.bin"), - artifact_digest: ManifestDigest::from_sha256_bytes( - &hex::decode(&artifact_hex).map_err(text_artifact_unavailable)?, - ) - .map_err(text_artifact_unavailable)?, - artifact_size_bytes, - }; - let final_path = artifacts_root.join(&descriptor.artifact_file); - match final_path.symlink_metadata() { - Ok(_) => { - // A digest-derived name is not proof that an existing filesystem - // object contains the named bytes. Verify the stable destination - // before withdrawing staging evidence; a symlink, non-regular - // object, truncated file, or same-name collision fails closed. - let (existing_hex, existing_size_bytes) = - sha256_private_file_hex_and_size(&final_path, control)?; - if existing_size_bytes != artifact_size_bytes { - return Err(RetrievalPortError::Contract( - "existing code text artifact does not match its content address".to_owned(), - )); + hotpath::measure_block!("query.artifact.store.publish", { + let artifacts_root = code_text_artifacts_root(&self.store_root); + ensure_private_text_artifacts_root(&artifacts_root)?; + // Publication and artifact retention share this canonical store lock. + // Hold it from the first staging observation until pointer attachment + // is durable so retention cannot unlink a newly visible artifact from + // a plan made before the descriptor was attached. + let lock = acquire_code_generation_store_lock(&self.store_root) + .map_err(text_artifact_unavailable)?; + let (artifact_hex, artifact_size_bytes) = hotpath::measure_block!( + "query.artifact.store.state_digest", + sha256_private_file_hex_and_size(staging_path, control) + )?; + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.store.digest_bytes").set(artifact_size_bytes); + let descriptor = DurableCodeTextArtifactDescriptorV1 { + generation_id: generation_id.clone(), + artifact_file: format!("text-artifact-{artifact_hex}.bin"), + artifact_digest: ManifestDigest::from_sha256_bytes( + &hex::decode(&artifact_hex).map_err(text_artifact_unavailable)?, + ) + .map_err(text_artifact_unavailable)?, + artifact_size_bytes, + }; + let final_path = artifacts_root.join(&descriptor.artifact_file); + match final_path.symlink_metadata() { + Ok(_) => { + // A digest-derived name is not proof that an existing filesystem + // object contains the named bytes. Verify the stable destination + // before withdrawing staging evidence; a symlink, non-regular + // object, truncated file, or same-name collision fails closed. + let (existing_hex, existing_size_bytes) = hotpath::measure_block!( + "query.artifact.store.dedupe_compare", + sha256_private_file_hex_and_size(&final_path, control) + )?; + if existing_size_bytes != artifact_size_bytes { + return Err(RetrievalPortError::Contract( + "existing code text artifact does not match its content address" + .to_owned(), + )); + } + if existing_hex != artifact_hex { + return Err(RetrievalPortError::Contract( + "existing code text artifact contains different bytes".to_owned(), + )); + } + std::fs::remove_file(staging_path).map_err(text_artifact_unavailable)?; } - if existing_hex != artifact_hex { - return Err(RetrievalPortError::Contract( - "existing code text artifact contains different bytes".to_owned(), - )); + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::rename(staging_path, &final_path) + .map_err(text_artifact_unavailable)?; } - std::fs::remove_file(staging_path).map_err(text_artifact_unavailable)?; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::rename(staging_path, &final_path).map_err(text_artifact_unavailable)?; + Err(error) => return Err(text_artifact_unavailable(error)), } - Err(error) => return Err(text_artifact_unavailable(error)), - } - DaemonCodeIndexPublicationStoreV1::sync_directory(&artifacts_root) - .map_err(text_artifact_unavailable)?; - let pointer = self - .publication - .read_publication_pointer() - .map_err(text_artifact_unavailable)? - .ok_or_else(|| { - RetrievalPortError::AuthorityUnavailable( - "no durable publication pointer exists for text-artifact attachment".to_owned(), + hotpath::measure_block!( + "query.artifact.store.seal_fsync", + DaemonCodeIndexPublicationStoreV1::sync_directory(&artifacts_root) + .map_err(text_artifact_unavailable) + )?; + let pointer = self + .publication + .read_publication_pointer() + .map_err(text_artifact_unavailable)? + .ok_or_else(|| { + RetrievalPortError::AuthorityUnavailable( + "no durable publication pointer exists for text-artifact attachment" + .to_owned(), + ) + })?; + hotpath::measure_block!( + "query.artifact.store.pointer_commit", + attach_verified_text_artifact_under_lock( + &lock, + &pointer, + sealed_identity, + descriptor.clone(), ) - })?; - attach_verified_text_artifact_under_lock( - &lock, - &pointer, - sealed_identity, - descriptor.clone(), - ) - .map_err(text_artifact_unavailable)?; - Ok(descriptor) + .map_err(text_artifact_unavailable) + )?; + Ok(descriptor) + }) } } @@ -2032,6 +2532,10 @@ enum CodeGraphServingAuthorityV1 { } impl LatestCompleteCodeIndexV1 { + pub(in crate::daemon) fn text_generation_handle(&self) -> LatestCodeTextGenerationV1 { + self.text.clone() + } + pub(in crate::daemon) fn generation(&self) -> &CodeIndexPublishedGenerationV1 { self.generation.as_ref() } @@ -2087,6 +2591,20 @@ impl LatestCompleteCodeIndexV1 { }; let _ = self.install_graph_serving(reader, None, CodeGraphServingAuthorityV1::Memory); } +} + +impl LatestCodeTextGenerationV1 { + pub(in crate::daemon) fn metadata(&self) -> &VerifiedSealedTextGenerationMetadataV1 { + &self.metadata + } + + pub(in crate::daemon) fn artifact_occurrence_by_chunk( + &self, + chunk: &tracedecay_domain::CodeSearchChunkId, + ) -> Result { + self.production_query_owners_with_budget(&queries::maximum_retrieval_budget())? + .artifact_occurrence_by_chunk(chunk) + } #[cfg(test)] fn activate_text_serving(&self) -> Result<(), RetrievalPortError> { @@ -2095,17 +2613,19 @@ impl LatestCompleteCodeIndexV1 { "code-index text serving owners are warming".to_owned(), )); } - let _ = self.record_index(); - let _ = self.generation.test_attribution_authority(); Ok(()) } +} +impl LatestCompleteCodeIndexV1 { /// Whether the record lookup indices are already built for this generation. #[cfg(test)] fn record_index_is_warm(&self) -> bool { self.record_index.get().is_some() } +} +impl LatestCodeTextGenerationV1 { /// Whether the exact/lexical lane owners are already built. #[cfg(test)] fn query_owners_are_warm(&self) -> bool { @@ -2120,10 +2640,16 @@ impl LatestCompleteCodeIndexV1 { !self.text_serving_is_ready() && !self.text_projection_failed.load(Ordering::Acquire) } + fn same_text_owner(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.text_projection_build, &other.text_projection_build) + } + fn mark_text_serving_failed(&self) { self.text_projection_failed.store(true, Ordering::Release); } +} +impl LatestCompleteCodeIndexV1 { fn semantic_evaluation_snapshot(&self) -> SemanticEvaluationCodeSnapshotV1 { SemanticEvaluationCodeSnapshotV1 { source_generation: self.generation.manifest().generation_id.clone(), @@ -2172,7 +2698,9 @@ impl LatestCompleteCodeIndexV1 { pub fn graph_abstentions(&self) -> &[crate::code_index::chunks::CodeIndexEdgeAbstentionV1] { self.generation.edge_abstentions() } +} +impl LatestCodeTextGenerationV1 { /// Return exact and lexical query owners bound to the latest complete /// published generation. #[cfg(test)] @@ -2198,7 +2726,9 @@ impl LatestCompleteCodeIndexV1 { ) }) } +} +impl LatestCompleteCodeIndexV1 { fn production_graph_serving( &self, ) -> Result, RetrievalPortError> { @@ -2253,10 +2783,12 @@ impl LatestCompleteCodeIndexV1 { Err(error) => Err(RetrievalPortError::Contract(error.to_string())), } } +} +impl LatestCodeTextGenerationV1 { fn source_freshness(&self) -> Result { production_code_index_freshness( - self.generation.manifest().seal.sealed_at, + self.metadata.manifest().seal.sealed_at, ComponentRevision::new("policy.daemon.v1") .map_err(|error| RetrievalPortError::Contract(error.to_string()))?, ) @@ -2265,13 +2797,13 @@ impl LatestCompleteCodeIndexV1 { fn text_projection_metadata( &self, ) -> Result { - let generation_id = self.generation.manifest().generation_id.clone(); + let generation_id = self.metadata.manifest().generation_id.clone(); let freshness = self.source_freshness()?; Ok(CodeLexicalProjectionMetadataV1 { generation: generation_id, - repository_id: Some(self.generation.snapshot().repository.clone()), + repository_id: Some(self.metadata.snapshot().repository.clone()), logical_paths: self - .generation + .metadata .snapshot() .files .iter() @@ -2293,12 +2825,241 @@ impl LatestCompleteCodeIndexV1 { }) } + fn publish_text_progress_boundary( + &self, + build: &CodeTextArtifactBuildV1, + progress: &tracedecay_query::retrieval::lexical::CodeLexicalArtifactBuildProgressV1, + phase: CodeIndexBuildPhaseV1, + current_batch_pages: u64, + current_batch_payload_bytes: u64, + last_commit_latency_micros: Option, + observe_committed: bool, + ) -> Result<(), RetrievalPortError> { + let source_cursor = build.source.cursor(); + match progress.next_cursor.as_ref() { + Some(cursor) if cursor == source_cursor => {} + None if progress.next_page_ordinal == 0 + && progress.completed_chunks == 0 + && progress.completed_payload_bytes == 0 + && progress.completed_imports == 0 + && source_cursor.next_page_ordinal() == 0 => {} + _ => { + return Err(RetrievalPortError::Contract( + "text-artifact progress does not match the accepted sealed-source cursor" + .to_owned(), + )); + } + } + if progress.next_page_ordinal != source_cursor.next_page_ordinal() + || progress.completed_chunks != source_cursor.emitted_chunks() + || progress.completed_payload_bytes != source_cursor.emitted_payload_bytes() + || progress.completed_imports != source_cursor.emitted_imports() + { + return Err(RetrievalPortError::Contract( + "text-artifact progress counters do not match the sealed-source cursor".to_owned(), + )); + } + let completed_files = build.source.completed_files(); + let completed_lexical_bytes = build + .source + .completed_lexical_bytes() + .map_err(map_sealed_page_source_error)?; + let total_lexical_bytes = build.source.total_lexical_bytes(); + let observed_at = Instant::now(); + let observed_micros = now_micros().0; + let last_commit_latency_micros = last_commit_latency_micros.or_else(|| { + self.text_progress_slot + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .snapshot() + .filter(|snapshot| { + snapshot.generation_id == self.metadata.manifest().generation_id.as_str() + }) + .and_then(|snapshot| snapshot.last_commit_latency_micros) + }); + let mut state = self + .text_progress_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if observe_committed && progress.next_page_ordinal > 0 { + state.observe_committed(CodeIndexCommittedProgressSampleV1 { + observed_at, + completed_files, + completed_lexical_bytes, + }); + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.progress.committed_pages") + .set(progress.next_page_ordinal); + hotpath::gauge!("query.artifact.progress.committed_lexical_bytes") + .set(completed_lexical_bytes); + } + } + let (files_per_second, lexical_bytes_per_second, estimated_remaining_seconds) = + state.rates_and_eta(total_lexical_bytes); + let snapshot = CodeIndexBuildProgressV1 { + generation_id: self.metadata.manifest().generation_id.as_str().to_owned(), + daemon_incarnation: self.text_progress_daemon_incarnation, + producer_incarnation: self.text_progress_producer_incarnation, + progress_epoch: 0, + sealed_source_digest: build.sealed_identity.digest.as_str().to_owned(), + phase, + committed_pages: progress.next_page_ordinal, + committed_chunks: progress.completed_chunks, + committed_imports: progress.completed_imports, + committed_payload_bytes: progress.completed_payload_bytes, + completed_files, + total_files: build.source.total_files(), + completed_lexical_bytes, + total_lexical_bytes, + current_batch_pages, + current_batch_payload_bytes, + elapsed_micros: state.elapsed_micros(), + last_commit_latency_micros, + files_per_second, + lexical_bytes_per_second, + estimated_remaining_seconds, + last_progress_micros: observed_micros, + blocked_reason: None, + }; + drop(state); + self.publish_text_progress_snapshot(snapshot); + Ok(()) + } + + fn ready_text_progress_snapshot( + &self, + reader: &CodeLexicalArtifactReaderV1, + sealed_identity: &DurableSealedCodeGenerationIdentityV1, + source: &VerifiedSealedLexicalPageSourceV1, + ) -> Result { + let artifact = reader.verified_artifact(); + let generation_id = &self.metadata.manifest().generation_id; + if artifact.generation() != generation_id { + return Err(RetrievalPortError::GenerationMismatch); + } + let elapsed_micros = self + .text_progress_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .elapsed_micros(); + Ok(CodeIndexBuildProgressV1 { + generation_id: generation_id.as_str().to_owned(), + daemon_incarnation: self.text_progress_daemon_incarnation, + producer_incarnation: self.text_progress_producer_incarnation, + progress_epoch: 0, + sealed_source_digest: sealed_identity.digest.as_str().to_owned(), + phase: CodeIndexBuildPhaseV1::Ready, + committed_pages: artifact.page_count(), + committed_chunks: artifact.total_chunks(), + committed_imports: artifact.total_imports(), + committed_payload_bytes: artifact.total_payload_bytes(), + completed_files: source.total_files(), + total_files: source.total_files(), + completed_lexical_bytes: source.total_lexical_bytes(), + total_lexical_bytes: source.total_lexical_bytes(), + current_batch_pages: 0, + current_batch_payload_bytes: 0, + elapsed_micros, + last_commit_latency_micros: None, + files_per_second: None, + lexical_bytes_per_second: None, + estimated_remaining_seconds: None, + last_progress_micros: now_micros().0, + blocked_reason: None, + }) + } + + fn publish_text_progress_snapshot(&self, snapshot: CodeIndexBuildProgressV1) { + let generation_id = &self.metadata.manifest().generation_id; + hotpath::measure_block!("query.artifact.progress.publish", { + let _ = self + .text_progress_slot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .publish(generation_id, self.text_progress_owner_epoch, snapshot); + }); + } + + fn publish_text_progress_phase( + &self, + phase: CodeIndexBuildPhaseV1, + current_batch_pages: u64, + current_batch_payload_bytes: u64, + ) { + let generation_id = &self.metadata.manifest().generation_id; + let elapsed_micros = self + .text_progress_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .elapsed_micros(); + hotpath::measure_block!("query.artifact.progress.publish", { + let mut slot = self + .text_progress_slot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(current) = slot.snapshot() else { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.progress.no_snapshot_total").inc(1u64); + return; + }; + let mut snapshot = current.as_ref().clone(); + snapshot.phase = phase; + snapshot.current_batch_pages = current_batch_pages; + snapshot.current_batch_payload_bytes = current_batch_payload_bytes; + snapshot.elapsed_micros = elapsed_micros; + snapshot.blocked_reason = None; + let _ = slot.publish(generation_id, self.text_progress_owner_epoch, snapshot); + }); + } + + fn publish_text_progress_blocked(&self, reason: CodeIndexBuildBlockedReasonV1) { + let generation_id = &self.metadata.manifest().generation_id; + hotpath::measure_block!("query.artifact.progress.publish", { + let mut slot = self + .text_progress_slot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(current) = slot.snapshot() else { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.progress.no_snapshot_total").inc(1u64); + return; + }; + let mut snapshot = current.as_ref().clone(); + snapshot.blocked_reason = Some(reason); + let _ = slot.publish(generation_id, self.text_progress_owner_epoch, snapshot); + }); + } + + #[cfg(feature = "hotpath")] + fn text_progress_phase(&self) -> Option { + self.text_progress_slot + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .snapshot() + .map(|snapshot| snapshot.phase) + } + /// Advance at most `maximum_work` bounded page/finalization operations on /// this sealed generation's durable text artifact. The mutex is both the /// generation-owned partial-state authority and the single-flight gate for /// concurrent scheduler wakes. fn advance_text_serving(&self, maximum_work: usize) -> Result { let result = self.advance_text_serving_inner(maximum_work); + if matches!(&result, Err(RetrievalPortError::Cancelled)) { + #[cfg(feature = "hotpath")] + match self.text_control.cancellation_source() { + Some(GenerationTextCancellationSourceV1::Shutdown) => { + hotpath::gauge!("query.artifact.cancelled.shutdown_total").inc(1_u64); + } + Some(GenerationTextCancellationSourceV1::Superseded) => { + hotpath::gauge!("query.artifact.cancelled.superseded_total").inc(1_u64); + } + None => { + hotpath::gauge!("query.artifact.cancelled.external_total").inc(1_u64); + } + } + } if result.as_ref().is_err_and(|error| { matches!( error, @@ -2314,9 +3075,21 @@ impl LatestCompleteCodeIndexV1 { } fn advance_text_serving_inner(&self, maximum_work: usize) -> Result { + if let Some(binding) = self.publication_binding.as_ref() { + let current = self + .text_artifact_store + .publication + .read_publication_pointer() + .map_err(text_artifact_unavailable)?; + if !binding.matches(current.as_ref()) { + self.text_control.retire(); + return Err(RetrievalPortError::Cancelled); + } + } if self.query_owners.get().is_some() { return Ok(true); } + let control = self.text_execution_control(); let mut build = self .text_projection_build .lock() @@ -2324,24 +3097,49 @@ impl LatestCompleteCodeIndexV1 { if self.query_owners.get().is_some() { return Ok(true); } - self.advance_artifact_text_serving(&mut build, maximum_work) + self.advance_artifact_text_serving(&mut build, maximum_work, &control) + } + + fn text_execution_control(&self) -> GenerationTextControlV1 { + self.text_control.clone() + } + + fn take_preopened_source_or_open( + &self, + sealed_identity: &DurableSealedCodeGenerationIdentityV1, + control: &dyn CodeIndexExecutionControlV1, + ) -> Result, RetrievalPortError> { + if let Some(source) = self + .preopened_source + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + return Ok(source); + } + self.text_artifact_store + .open_sealed_source(sealed_identity, control) } /// The durable-artifact journey: reopen a published head when one exists, /// otherwise stream the sealed generation through the staging builder one /// bounded page window at a time, finalize, publish, and reopen. + #[hotpath::measure(label = "query.artifact.batch.scheduler_wake")] fn advance_artifact_text_serving( &self, build: &mut Option, maximum_work: usize, + control: &dyn CodeIndexExecutionControlV1, ) -> Result { let store = &self.text_artifact_store; - let control = DaemonCodeIndexControlV1::new( - Arc::clone(&self.text_control_epoch), - Arc::clone(&self.text_control_shutdown), - ); if build.is_none() { - let generation_id = self.generation.manifest().generation_id.clone(); + hotpath::gauge!("query.artifact.build_memory_budget_bytes") + .set(CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1); + hotpath::gauge!("query.artifact.source_batch_pages_max") + .set(TEXT_ARTIFACT_BATCH_PAGES_V1); + hotpath::gauge!("query.artifact.source_batch_bytes_max") + .set(TEXT_ARTIFACT_BATCH_BYTES_V1); + let generation_id = self.metadata.manifest().generation_id.clone(); if let Some(descriptor) = store.published_descriptor(&generation_id)? { // Durable-head reopen: a restart serves the published // artifact without rebuilding it. Reserve the complete reader @@ -2358,11 +3156,18 @@ impl LatestCompleteCodeIndexV1 { &descriptor.artifact_digest, descriptor.artifact_size_bytes, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, - &control, + control, ); match reader { Ok(reader) => { + let sealed_identity = store.sealed_identity(&generation_id)?; + let source = + self.take_preopened_source_or_open(&sealed_identity, control)?; + let ready_progress = + self.ready_text_progress_snapshot(&reader, &sealed_identity, &source)?; + drop(source); self.install_artifact_owners(reader, reader_reservation)?; + self.publish_text_progress_snapshot(ready_progress); return Ok(true); } Err(CodeLexicalArtifactErrorV1::Missing(_)) => { @@ -2400,7 +3205,7 @@ impl LatestCompleteCodeIndexV1 { let artifacts_root = code_text_artifacts_root(store.store_root()); ensure_private_text_artifacts_root(&artifacts_root)?; let staging_path = artifacts_root.join(format!(".text-artifact-{sealed_hex}.staging")); - let mut source = store.open_sealed_source(&sealed_identity, &control)?; + let mut source = self.take_preopened_source_or_open(&sealed_identity, control)?; let builder_budget = text_artifact_builder_budget(source.staging_window_bytes())?; let metadata = self.text_projection_metadata()?; let builder = if staging_path.exists() { @@ -2408,11 +3213,11 @@ impl LatestCompleteCodeIndexV1 { &staging_path, metadata.clone(), builder_budget, - &control, + control, ) { Ok(builder) => Ok(builder), Err(CodeLexicalArtifactErrorV1::Incompatible(_)) => { - store.discard_incompatible_staging(&staging_path, &control)?; + store.discard_incompatible_staging(&staging_path, control)?; CodeLexicalArtifactBuilderV1::create_with_memory_budget( &staging_path, metadata, @@ -2432,17 +3237,27 @@ impl LatestCompleteCodeIndexV1 { let progress = builder.progress().map_err(map_text_artifact_error)?; if let Some(cursor) = progress.next_cursor.as_ref() { source - .restore_cursor(cursor, &control) + .restore_cursor(cursor, control) .map_err(map_sealed_page_source_error)?; } - *build = Some(CodeTextArtifactBuildV1 { + let initialized = CodeTextArtifactBuildV1 { builder, source, sealed_identity, source_receipt: None, staging_path, _build_reservation: build_reservation, - }); + }; + self.publish_text_progress_boundary( + &initialized, + &progress, + CodeIndexBuildPhaseV1::SourceScan, + 0, + 0, + None, + true, + )?; + *build = Some(initialized); } let artifact_build = build.as_mut().ok_or_else(|| { RetrievalPortError::Contract( @@ -2451,20 +3266,158 @@ impl LatestCompleteCodeIndexV1 { })?; let mut remaining = maximum_work.min(TEXT_ARTIFACT_MAXIMUM_WORK_PER_ADVANCE_V1); while remaining > 0 && artifact_build.source_receipt.is_none() { - let (source, builder) = (&mut artifact_build.source, &mut artifact_build.builder); - let admitted = source - .next_page_if(&control, |page| { - builder.append_page(page, &control).map(|_| ()) + let maximum_batch_pages = remaining.clamp(1, TEXT_ARTIFACT_BATCH_PAGES_V1); + let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new( + maximum_batch_pages, + TEXT_ARTIFACT_BATCH_BYTES_V1, + ) + .map_err(map_sealed_page_source_error)?; + #[cfg(feature = "hotpath")] + let completed_lexical_bytes_before = artifact_build + .source + .completed_lexical_bytes() + .map_err(map_sealed_page_source_error)?; + self.publish_text_progress_phase(CodeIndexBuildPhaseV1::SourceScan, 0, 0); + let mut durable_progress = None; + let mut commit_latency_micros = None; + let admitted = { + let (source, builder) = (&mut artifact_build.source, &mut artifact_build.builder); + source.next_page_batch_if(control, bounds, |pages| { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.batch.offered_pages_total") + .inc(u64::try_from(pages.len()).unwrap_or(u64::MAX)); + let offered_batch_pages = u64::try_from(pages.len()).map_err(|_| { + CodeLexicalArtifactErrorV1::Contract( + "text-artifact batch page count exceeds u64".to_owned(), + ) + })?; + let offered_payload_bytes = pages.iter().try_fold(0_u64, |total, page| { + total.checked_add(page.payload_bytes()).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "text-artifact batch payload bytes overflowed".to_owned(), + ) + }) + })?; + self.publish_text_progress_phase( + CodeIndexBuildPhaseV1::RelationalPreparation, + offered_batch_pages, + offered_payload_bytes, + ); + let (progress, accepted) = + hotpath::measure_block!("query.artifact.batch.builder", { + let prepared = + builder.prepare_admissible_page_prefix(pages, control)?; + let accepted = prepared.accepted_prefix(); + let accepted_pages = &pages[..accepted.get()]; + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.batch.accepted_pages_total") + .inc(u64::try_from(accepted_pages.len()).unwrap_or(u64::MAX)); + let batch_pages = + u64::try_from(accepted_pages.len()).map_err(|_| { + CodeLexicalArtifactErrorV1::Contract( + "text-artifact accepted page count exceeds u64".to_owned(), + ) + })?; + let batch_payload_bytes = + accepted_pages.iter().try_fold(0_u64, |total, page| { + total.checked_add(page.payload_bytes()).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "text-artifact accepted payload bytes overflowed" + .to_owned(), + ) + }) + })?; + self.publish_text_progress_phase( + CodeIndexBuildPhaseV1::BulkCommit, + batch_pages, + batch_payload_bytes, + ); + let commit_started = Instant::now(); + let progress = builder + .append_prepared_pages(prepared.prepared_pages(), control)?; + commit_latency_micros = Some( + u64::try_from(commit_started.elapsed().as_micros()) + .unwrap_or(u64::MAX), + ); + Ok::<_, CodeLexicalArtifactErrorV1>((progress, accepted)) + })?; + durable_progress = Some(progress); + Ok(accepted) }) - .map_err(map_sealed_page_source_error)? - .map_err(map_text_artifact_error)?; + }; + let admitted = match admitted { + Ok(Ok(admitted)) => admitted, + Ok(Err(error @ CodeLexicalArtifactErrorV1::BatchTooLarge { .. })) => { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.batch.refusal_total").inc(1u64); + return Err(map_text_artifact_error(error)); + } + Ok(Err(error @ CodeLexicalArtifactErrorV1::Unreserved(_))) => { + self.publish_text_progress_blocked( + CodeIndexBuildBlockedReasonV1::ResidentMemory, + ); + return Err(map_text_artifact_error(error)); + } + Ok(Err( + error @ (CodeLexicalArtifactErrorV1::Io(_) + | CodeLexicalArtifactErrorV1::Missing(_)), + )) => { + self.publish_text_progress_blocked( + CodeIndexBuildBlockedReasonV1::ArtifactStoreUnavailable, + ); + return Err(map_text_artifact_error(error)); + } + Ok(Err(error)) => return Err(map_text_artifact_error(error)), + Err(error) => return Err(map_sealed_page_source_error(error)), + }; match admitted { - VerifiedSealedLexicalPageReadV1::Page(page) => { - let _ = page; - remaining -= 1; + VerifiedSealedLexicalPageBatchReadV1::Pages(pages) => { + let page_count = pages.len(); + let progress = durable_progress.as_ref().ok_or_else(|| { + RetrievalPortError::Contract( + "accepted text-artifact batch has no durable builder progress" + .to_owned(), + ) + })?; + let batch_payload_bytes = pages.iter().try_fold(0_u64, |total, page| { + total.checked_add(page.payload_bytes()).ok_or_else(|| { + RetrievalPortError::Contract( + "accepted text-artifact batch payload bytes overflowed".to_owned(), + ) + }) + })?; + self.publish_text_progress_boundary( + artifact_build, + progress, + CodeIndexBuildPhaseV1::BulkCommit, + u64::try_from(page_count).unwrap_or(u64::MAX), + batch_payload_bytes, + commit_latency_micros, + true, + )?; + #[cfg(feature = "hotpath")] + { + let committed_lexical_bytes = artifact_build + .source + .completed_lexical_bytes() + .map_err(map_sealed_page_source_error)? + .saturating_sub(completed_lexical_bytes_before); + hotpath::gauge!("query.artifact.batch.committed_lexical_bytes_total") + .inc(committed_lexical_bytes); + if let Some(latency_micros) = commit_latency_micros { + hotpath::gauge!("query.artifact.progress.latest_commit_latency_micros") + .set(latency_micros); + } + } + remaining = remaining.checked_sub(page_count).ok_or_else(|| { + RetrievalPortError::Contract( + "accepted text-artifact batch exceeded its work budget".to_owned(), + ) + })?; } - VerifiedSealedLexicalPageReadV1::Complete(receipt) => { + VerifiedSealedLexicalPageBatchReadV1::Complete(receipt) => { artifact_build.source_receipt = Some(receipt); + self.publish_text_progress_phase(CodeIndexBuildPhaseV1::IndexBuild, 0, 0); } } } @@ -2481,16 +3434,73 @@ impl LatestCompleteCodeIndexV1 { "code text artifact finalization work budget overflowed".to_owned(), ) })?; - let finalized = artifact_build + #[cfg(feature = "hotpath")] + let finalized = if matches!( + self.text_progress_phase(), + Some(CodeIndexBuildPhaseV1::Verification) + ) { + hotpath::measure_block!("query.artifact.finalization.digest_verify_wake", { + artifact_build.builder.advance_finalization( + source_receipt, + finalization_rows, + control, + ) + }) + } else { + hotpath::measure_block!("query.artifact.index.build", { + artifact_build.builder.advance_finalization( + source_receipt, + finalization_rows, + control, + ) + }) + }; + #[cfg(not(feature = "hotpath"))] + let finalized = + artifact_build + .builder + .advance_finalization(source_receipt, finalization_rows, control); + let finalized = finalized.map_err(map_text_artifact_error)?; + let finalization_phase = match finalized { + CodeLexicalArtifactFinalizationStepV1::Pending { phase, .. } => { + let phase = match phase { + CodeLexicalArtifactFinalizationPhaseV1::IndexBuild => { + CodeIndexBuildPhaseV1::IndexBuild + } + CodeLexicalArtifactFinalizationPhaseV1::Verification => { + CodeIndexBuildPhaseV1::Verification + } + }; + let progress = artifact_build + .builder + .progress() + .map_err(map_text_artifact_error)?; + self.publish_text_progress_boundary( + artifact_build, + &progress, + phase, + 0, + 0, + None, + false, + )?; + return Ok(false); + } + CodeLexicalArtifactFinalizationStepV1::Ready(_) => CodeIndexBuildPhaseV1::Verification, + }; + let progress = artifact_build .builder - .advance_finalization(source_receipt, finalization_rows, &control) + .progress() .map_err(map_text_artifact_error)?; - if !matches!( - finalized, - tracedecay_query::retrieval::lexical::CodeLexicalArtifactFinalizationStepV1::Ready(_) - ) { - return Ok(false); - } + self.publish_text_progress_boundary( + artifact_build, + &progress, + finalization_phase, + 0, + 0, + None, + false, + )?; let finished = build.take().ok_or_else(|| { RetrievalPortError::Contract( "code-index text artifact build state vanished during publication".to_owned(), @@ -2510,12 +3520,12 @@ impl LatestCompleteCodeIndexV1 { drop(source); let descriptor = store.publish( &staging_path, - self.generation.as_ref(), + &self.metadata.manifest().generation_id, &sealed_identity, - &control, + control, )?; let reader_reservation = store.reserve_resident_memory( - &self.generation.manifest().generation_id, + &self.metadata.manifest().generation_id, "code-text-artifact-reader", CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, )?; @@ -2526,10 +3536,11 @@ impl LatestCompleteCodeIndexV1 { &descriptor.artifact_digest, descriptor.artifact_size_bytes, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, - &control, + control, ) .map_err(map_text_artifact_error)?; self.install_artifact_owners(reader, reader_reservation)?; + self.publish_text_progress_phase(CodeIndexBuildPhaseV1::Ready, 0, 0); drop(build_reservation); Ok(true) } @@ -2546,18 +3557,20 @@ impl LatestCompleteCodeIndexV1 { } let authority = exact_serving_authority()?; let exact = ExactLane::new(authority.clone(), reader.exact_adapter(authority)); + let hydration = reader.clone(); let lexical = LexicalLane::new(reader); let owners = Arc::new(ProductionCodeIndexQueryOwnersV1::artifact( exact, lexical, + hydration, reader_reservation, )); let _ = self.query_owners.set(owners); - let _ = self.record_index(); - let _ = self.generation.test_attribution_authority(); Ok(()) } +} +impl LatestCompleteCodeIndexV1 { fn install_graph_serving( &self, graph_reader: CodeGraphEvidenceReader, @@ -2615,8 +3628,6 @@ pub(super) enum CodeIndexSchedulerErrorV1 { SnapshotMemoryAdmission(ResidentMemoryAdmissionFailureV1), #[error("code-index retained-source resident-memory capacity is unavailable")] SnapshotMemoryCapacityUnavailable, - #[error("code-index retained-source resident-memory adjustment failed: {0}")] - SnapshotMemoryAdjustment(ResidentMemoryAdjustmentFailureV1), #[error("code-index worker plan refused: {0}")] WorkerPlan(#[from] tracedecay_code_index::parallelism::CodeIndexWorkerPlanInstallErrorV1), #[cfg(not(test))] @@ -2663,7 +3674,6 @@ impl CodeIndexSchedulerErrorV1 { | Self::WorkerMemoryAdmission(_) | Self::SnapshotMemoryAdmission(_) | Self::SnapshotMemoryCapacityUnavailable - | Self::SnapshotMemoryAdjustment(_) | Self::WorkerPlan(_) => false, #[cfg(not(test))] Self::WorkerPlanNotInstalled => false, @@ -2757,6 +3767,14 @@ pub(super) struct CodeIndexWorktreeSchedulerV1 { latest_content_identity: Option, ignored_source_admissions: Vec, query_owners: Mutex>, + /// Immutable generation-scoped build snapshot. The registry clones this + /// slot at mount so dashboard reads never acquire the scheduler mutex. + build_progress: CodeIndexBuildProgressSlotV1, + /// Durable daemon-authority epoch bound by the process registry at mount. + progress_daemon_incarnation: u64, + /// Registry-minted scheduler-owner token. A same-daemon retire/remount gets + /// a strictly newer token so delayed progress cannot outrank the new owner. + progress_producer_incarnation: u64, /// Optional semantic hook: schedule `FastEmbed` projection without joining it. semantic_schedule: Option, @@ -2860,6 +3878,9 @@ impl CodeIndexWorktreeSchedulerV1 { latest_content_identity, ignored_source_admissions: Vec::new(), query_owners: Mutex::new(None), + build_progress: Arc::new(RwLock::new(CodeIndexBuildProgressSlotStateV1::default())), + progress_daemon_incarnation: 1, + progress_producer_incarnation: 1, semantic_schedule: None, }; Ok(scheduler) @@ -2876,6 +3897,27 @@ impl CodeIndexWorktreeSchedulerV1 { self.resident_memory = resident_memory; } + pub(super) fn bind_progress_incarnations( + &mut self, + daemon_incarnation: u64, + producer_incarnation: u64, + ) { + self.progress_daemon_incarnation = daemon_incarnation.max(1); + self.progress_producer_incarnation = producer_incarnation.max(1); + } + + #[cfg(test)] + pub(super) const fn progress_incarnations_for_test(&self) -> (u64, u64) { + ( + self.progress_daemon_incarnation, + self.progress_producer_incarnation, + ) + } + + pub(super) fn build_progress_slot(&self) -> CodeIndexBuildProgressSlotV1 { + Arc::clone(&self.build_progress) + } + /// Reserve the installed worker plan on the canonical process authority. /// The returned RAII guard spans source capture and the complete production /// build, releasing on success, typed failure, cancellation, or unwind. @@ -2913,10 +3955,7 @@ impl CodeIndexWorktreeSchedulerV1 { content_digest: &ContentDigest, retained_bytes: usize, ) -> Result, CodeIndexSchedulerErrorV1> { - let Some(requested_bytes) = u64::try_from(retained_bytes) - .ok() - .and_then(|bytes| bytes.checked_mul(2)) - .and_then(NonZeroU64::new) + let Some(requested_bytes) = u64::try_from(retained_bytes).ok().and_then(NonZeroU64::new) else { if retained_bytes == 0 { return Ok(None); @@ -2947,13 +3986,8 @@ impl CodeIndexWorktreeSchedulerV1 { } fn finish_snapshot_build_memory( - reservations: &mut [ResidentMemoryReservationV1], + _reservations: &mut [ResidentMemoryReservationV1], ) -> Result<(), CodeIndexSchedulerErrorV1> { - for reservation in reservations { - reservation - .shrink_to(reservation.reserved_bytes() / 2) - .map_err(CodeIndexSchedulerErrorV1::SnapshotMemoryAdjustment)?; - } Ok(()) } @@ -3147,6 +4181,224 @@ impl CodeIndexWorktreeSchedulerV1 { ))) } + /// Verify an unchanged retained text generation without decoding the full + /// graph-bearing generation. + /// + /// Graph-off mounts already authenticated the complete sealed bytes while + /// opening their lexical page source. For an ordinary source roster, the + /// durable freshness witness proves a quiet mount; an explicit hint is + /// settled by one authoritative capture. An unchanged capture keeps the + /// retained generation, while a changed ordinary source is rebuilt from + /// that capture under an exact durable-pointer compare-and-swap. This path + /// never decodes the graph-bearing active generation. Ignored-source + /// rosters still require the complete reconcile path. + fn reconcile_retained_text_generation( + &mut self, + metadata: &VerifiedSealedTextGenerationMetadataV1, + ) -> Result, CodeIndexSchedulerErrorV1> { + if self.shutting_down.load(Ordering::Acquire) { + return Err(cancelled_code_index_reconcile()); + } + if metadata.manifest().project_id != self.project_id + || metadata.snapshot().repository != self.repository_id + || metadata.snapshot().worktree.as_ref() != Some(&self.worktree_id) + { + return Ok(None); + } + let witness = RestoreFreshnessWitnessV1::load(&self.store_root); + if witness.as_ref().is_some_and(|witness| { + witness.generation_id != metadata.manifest().generation_id.as_str() + || !witness.ignored_source_paths.is_empty() + }) || !self.ignored_source_admissions.is_empty() + { + return Ok(None); + } + let resolved = identity::IndexingIdentityV1::resolve(&self.project_root) + .map_err(|error| CodeIndexSchedulerErrorV1::Identity(error.to_string()))?; + if !resolved.authorizes_reuse_of(&self.identity) { + return Err(CodeIndexSchedulerErrorV1::Identity( + "worktree identity changed under the scheduler".to_owned(), + )); + } + self.identity = resolved; + let sampled_metadata = identity::GitMetadataFingerprintV1::capture(&self.project_root); + let Some(sampled_signature) = self.worktree_stat_signature().ok() else { + return Ok(None); + }; + let has_hints = { + let hints = self + .hints + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + hints.overflow || !hints.paths.is_empty() + }; + if !has_hints && let Some(witness) = witness.as_ref() { + if witness.git_metadata_signature != sampled_metadata.stable_signature() + || witness.stat_signature != sampled_signature + { + return Ok(None); + } + let snapshot_content_identity = metadata.snapshot().content_identity.clone(); + self.latest_content_identity = Some(snapshot_content_identity.clone()); + self.mark_reconciled_state(sampled_metadata, Some(sampled_signature)); + return Ok(Some(CodeIndexReconcileOutcomeV1::Noop( + CodeIndexNoopEvidenceV1 { + snapshot_content_identity, + overflow_reconciled: false, + }, + ))); + } + + let _worker_memory = self.reserve_worker_memory()?; + let capture_epoch = self.epoch.load(Ordering::Acquire); + let mut captured = + self.capture_authoritative_snapshot_without_active_generation_reuse(None)?; + let drained_hints = { + let mut hints = self + .hints + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.epoch.load(Ordering::Acquire) != capture_epoch { + return Ok(None); + } + DrainedPendingHintsV1::new(Arc::clone(&self.hints), hints.take()) + }; + if captured.snapshot.reference != metadata.snapshot().reference + || captured.snapshot.source_revision != metadata.snapshot().source_revision + || captured.snapshot.content_identity != metadata.snapshot().content_identity + { + if witness.is_none() { + // Without the durable ignored-source roster witness, an + // unequal capture cannot distinguish a real edit from a file + // the sealed generation intentionally excluded. Fall back to + // the complete authority for that changed-source case. An + // equal authoritative capture is sufficient to verify the + // retained text generation without decoding it. + return Ok(None); + } + let pointer = self + .publication + .read_publication_pointer() + .map_err(CodeIndexProductionErrorV1::Publication)? + .ok_or_else(|| { + CodeIndexSchedulerErrorV1::PublicationConflict( + "the retained text generation has no active durable publication".to_owned(), + ) + })?; + if pointer.generation_id != metadata.manifest().generation_id.as_str() + || pointer.snapshot_content_identity + != metadata.snapshot().content_identity.as_str() + { + return Err(CodeIndexSchedulerErrorV1::PublicationConflict( + "the retained text generation was superseded before rebuild".to_owned(), + )); + } + let publication = self.publication.for_undecoded_active_rebuild(&pointer); + let mut owner = open_production_code_index_owner_v1( + self.production_config.clone(), + publication, + DaemonProjectionSinkV1, + ) + .map_err(|error| CodeIndexSchedulerErrorV1::ProductionOpen(error.to_string()))? + .with_physical_artifact_pool(self.byte_pool.physical_artifacts.clone()); + let control = DaemonCodeIndexControlV1::new( + Arc::clone(&self.epoch), + Arc::clone(&self.shutting_down), + ); + let snapshot_content_identity = captured.snapshot.content_identity.clone(); + let reextracted_files = captured.changed_paths.len(); + let generation = owner.build_and_publish( + CodeIndexBuildRequestV1 { + snapshot: captured.snapshot, + captured_files: captured.captured_files, + changed_files: captured.changed_paths, + invalidations: BTreeSet::new(), + repository_parse_identity: captured.repository_parse_identity, + ignored_source_admissions: Vec::new(), + sealed_at: now_micros(), + target_projection_key: projection_key()?, + }, + &control, + )?; + Self::finish_snapshot_build_memory(&mut captured.retained_reservations)?; + self.retained_snapshot_bytes = std::mem::take(&mut captured.retained_bytes); + self._retained_snapshot_memory = std::mem::take(&mut captured.retained_reservations); + self.latest_content_identity = Some(snapshot_content_identity); + self.mark_reconciled_state(sampled_metadata.clone(), Some(sampled_signature.clone())); + let repository_parse_identity_digest = + canonical_sha256(generation.repository_parse_identity()) + .map_err(|error| CodeIndexSchedulerErrorV1::Identity(error.to_string()))?; + RestoreFreshnessWitnessV1 { + generation_id: generation.manifest().generation_id.as_str().to_owned(), + git_metadata_signature: sampled_metadata.stable_signature(), + stat_signature: sampled_signature, + repository_parse_identity_digest: repository_parse_identity_digest + .as_str() + .to_owned(), + ignored_source_admissions_digest: generation + .ignored_source_admissions_digest() + .as_str() + .to_owned(), + ignored_source_paths: Vec::new(), + } + .persist(&self.store_root); + let changes = &generation.projection().request().changes; + let lane_digest = canonical_sha256(&( + generation.snapshot().content_identity.clone(), + generation + .chunks() + .chunks() + .iter() + .map(|chunk| (&chunk.id, &chunk.content_digest)) + .collect::>(), + generation.edges(), + )) + .map_err(|error| CodeIndexSchedulerErrorV1::Identity(error.to_string()))?; + let outcome = CodeIndexReconcileOutcomeV1::Published(CodeIndexPublishEvidenceV1 { + generation_id: generation.manifest().generation_id.clone(), + repository_id: self.repository_id.clone(), + snapshot_content_identity: generation.snapshot().content_identity.clone(), + _lane_digest: lane_digest, + _file_occurrence_ids: generation + .snapshot() + .files + .iter() + .map(|file| file.file_occurrence_id.clone()) + .collect(), + reextracted_files, + changed_chunks: changes.added_or_changed.len() + changes.deleted.len(), + reused_chunks: changes.reused.len(), + overflow_reconciled: drained_hints.overflow(), + }); + drained_hints.commit(); + return Ok(Some(outcome)); + } + drop(std::mem::take(&mut captured.captured_files)); + Self::finish_snapshot_build_memory(&mut captured.retained_reservations)?; + self.retained_snapshot_bytes = std::mem::take(&mut captured.retained_bytes); + self._retained_snapshot_memory = std::mem::take(&mut captured.retained_reservations); + let snapshot_content_identity = captured.snapshot.content_identity; + self.latest_content_identity = Some(snapshot_content_identity.clone()); + self.mark_reconciled_state(sampled_metadata.clone(), Some(sampled_signature.clone())); + if let Some(witness) = witness { + RestoreFreshnessWitnessV1 { + generation_id: witness.generation_id, + git_metadata_signature: sampled_metadata.stable_signature(), + stat_signature: sampled_signature, + repository_parse_identity_digest: witness.repository_parse_identity_digest, + ignored_source_admissions_digest: witness.ignored_source_admissions_digest, + ignored_source_paths: witness.ignored_source_paths, + } + .persist(&self.store_root); + } + let outcome = CodeIndexReconcileOutcomeV1::Noop(CodeIndexNoopEvidenceV1 { + snapshot_content_identity, + overflow_reconciled: drained_hints.overflow(), + }); + drained_hints.commit(); + Ok(Some(outcome)) + } + /// Load a complete identity-valid generation for stale serving. /// /// This does not claim freshness: a cancelled refresh or live ref switch @@ -3174,7 +4426,140 @@ impl CodeIndexWorktreeSchedulerV1 { Some(self.bind_latest_complete(generation)) } + /// Bind exact/lexical serving directly from the canonical active pointer. + /// + /// This authenticates the complete sealed content address and only decodes + /// its bounded manifest/snapshot header. Graph, record-index, attribution, + /// and semantic owners retain the full-generation decode path. + pub(super) fn servable_retained_text_generation( + &mut self, + ) -> Option { + if self.shutting_down.load(Ordering::Acquire) { + return None; + } + let resolved = identity::IndexingIdentityV1::resolve(&self.project_root).ok()?; + if !resolved.authorizes_reuse_of(&self.identity) { + return None; + } + let pointer = self.publication.read_publication_pointer().ok().flatten()?; + let generation_id = CodeGenerationId::new(pointer.generation_id.clone()).ok()?; + let entry = pointer + .generation_index + .iter() + .find(|entry| entry.generation_id == pointer.generation_id)?; + let sealed_identity = DurableSealedCodeGenerationIdentityV1 { + locator: entry.generation_file.clone(), + digest: ManifestDigest::new(entry.state_digest.clone()).ok()?, + size_bytes: entry.size_bytes, + }; + let text_progress_owner_epoch = hotpath::measure_block!( + "query.artifact.progress.publish", + self.build_progress + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .replace_generation(generation_id.clone()) + ); + let text_progress_state = Arc::new(Mutex::new(CodeIndexBuildProgressStateV1::new())); + let text_control = GenerationTextControlV1::new(Arc::clone(&self.shutting_down)); + let text_artifact_store = DaemonCodeTextArtifactStoreV1::bind( + &self.store_root, + &self.publication, + &self.resident_memory, + &self.project_id, + &self.worktree_id, + ); + let progress_slot = Arc::clone(&self.build_progress); + let progress_generation = generation_id.clone(); + let progress_digest = sealed_identity.digest.as_str().to_owned(); + let progress_state = Arc::clone(&text_progress_state); + let progress_daemon_incarnation = self.progress_daemon_incarnation; + let progress_producer_incarnation = self.progress_producer_incarnation; + let source = text_artifact_store + .open_sealed_source_with_progress( + &sealed_identity, + &text_control, + move |scanned, total| { + let elapsed_micros = progress_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .elapsed_micros(); + let snapshot = CodeIndexBuildProgressV1 { + generation_id: progress_generation.as_str().to_owned(), + daemon_incarnation: progress_daemon_incarnation, + producer_incarnation: progress_producer_incarnation, + progress_epoch: 0, + sealed_source_digest: progress_digest.clone(), + phase: CodeIndexBuildPhaseV1::SourceScan, + committed_pages: 0, + committed_chunks: 0, + committed_imports: 0, + committed_payload_bytes: 0, + completed_files: 0, + total_files: 0, + completed_lexical_bytes: scanned, + total_lexical_bytes: total, + current_batch_pages: 0, + current_batch_payload_bytes: 0, + elapsed_micros, + last_commit_latency_micros: None, + files_per_second: None, + lexical_bytes_per_second: None, + estimated_remaining_seconds: None, + last_progress_micros: now_micros().0, + blocked_reason: None, + }; + let _ = progress_slot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .publish(&progress_generation, text_progress_owner_epoch, snapshot); + }, + ) + .ok()?; + let metadata = source.metadata(); + if metadata.manifest().project_id != self.project_id + || metadata.manifest().generation_id != generation_id + || metadata.snapshot().repository != self.repository_id + || metadata.snapshot().worktree.as_ref() != Some(&self.worktree_id) + || metadata.snapshot().content_identity.as_str() != pointer.snapshot_content_identity + { + text_control.retire(); + return None; + } + if self + .publication + .read_publication_pointer() + .ok() + .flatten() + .as_ref() + != Some(&pointer) + { + text_control.retire(); + return None; + } + let metadata = Arc::new(source.metadata().clone()); + Some(LatestCodeTextGenerationV1 { + metadata, + query_owners: Arc::new(OnceLock::new()), + text_projection_build: Arc::new(Mutex::new(None)), + text_projection_failed: Arc::new(AtomicBool::new(false)), + text_control, + text_progress_state, + text_progress_slot: Arc::clone(&self.build_progress), + text_progress_owner_epoch, + text_progress_daemon_incarnation: self.progress_daemon_incarnation, + text_progress_producer_incarnation: self.progress_producer_incarnation, + text_artifact_store, + preopened_source: Arc::new(Mutex::new(Some(source))), + publication_binding: Some(Arc::new(DurableActiveSealedGenerationBindingV1 { + generation_id, + generation_file: pointer.generation_file, + state_digest: ManifestDigest::new(pointer.state_digest).ok()?, + })), + }) + } + /// Retained-owner activation entry point. Foreground reads never call this. + #[hotpath::measure(label = "code_index.reconcile.pass")] pub(super) fn activate_or_reconcile( &mut self, ) -> Result { @@ -3362,6 +4747,15 @@ impl CodeIndexWorktreeSchedulerV1 { &mut self, metadata: identity::GitMetadataFingerprintV1, signature: Option, + ) { + self.mark_reconciled_state(metadata, signature); + self.persist_freshness_witness(); + } + + fn mark_reconciled_state( + &mut self, + metadata: identity::GitMetadataFingerprintV1, + signature: Option, ) { self.git_metadata = metadata; self.last_stat_signature = signature; @@ -3369,7 +4763,6 @@ impl CodeIndexWorktreeSchedulerV1 { self.last_reconciled_at = Instant::now(); self.last_reconciled_at_micros = Some(now_micros().0); self.verified_against_source = true; - self.persist_freshness_witness(); } /// Record the restore-time freshness witness for the current active @@ -3458,12 +4851,25 @@ impl CodeIndexWorktreeSchedulerV1 { &mut self, admission: GenerationDecodeAdmissionV1, ) -> Result, CodeIndexSchedulerErrorV1> { - let latest = self.latest_complete_ready_for_query_with(admission)?; - if latest.is_none() { + if self.shutting_down.load(Ordering::Acquire) { + return Err(cancelled_code_index_reconcile()); + } + if self.freshness_unknown + || identity::GitMetadataFingerprintV1::capture(&self.project_root) + .differs_from(&self.git_metadata) + { + self.request_background_reconcile(); return Ok(None); } match self.worktree_stat_signature() { - Ok(signature) if self.last_stat_signature.as_ref() == Some(&signature) => Ok(latest), + Ok(signature) if self.last_stat_signature.as_ref() == Some(&signature) => { + // The exact-source stat fence is stronger than the elapsed + // tier-2 arm. Refresh only the monotonic admission clock: no + // reconcile receipt or wall timestamp is fabricated, and a + // clean status census cannot turn into a full capture loop. + self.last_reconciled_at = Instant::now(); + Ok(self.latest_complete_with(admission)) + } _ => { self.request_background_reconcile(); Ok(None) @@ -3579,12 +4985,23 @@ impl CodeIndexWorktreeSchedulerV1 { if !self.verified_against_source || identity::GitMetadataFingerprintV1::capture(&self.project_root) .differs_from(&self.git_metadata) - || self.last_reconciled_at.elapsed() >= self.policy.staleness_threshold { self.request_background_reconcile(); return true; } - false + if self.last_reconciled_at.elapsed() < self.policy.staleness_threshold { + return false; + } + if self + .worktree_stat_signature() + .is_ok_and(|signature| self.last_stat_signature.as_ref() == Some(&signature)) + { + self.last_reconciled_at = Instant::now(); + self.last_reconciled_at_micros = Some(now_micros().0); + return false; + } + self.request_background_reconcile(); + true } /// The exact identity this scheduler is currently bound to. @@ -3667,51 +5084,98 @@ impl CodeIndexWorktreeSchedulerV1 { record_index, text_projection_build, text_projection_failed, + text_control, + text_progress_state, + text_progress_owner_epoch, graph_activation, ) = match cached.as_ref() { - Some((cached_id, owners, index, build, failed, interactive)) - if cached_id == &generation_id => - { - ( - Arc::clone(owners), - Arc::clone(index), - Arc::clone(build), - Arc::clone(failed), - Arc::clone(interactive), - ) - } + Some(( + cached_id, + owners, + index, + build, + failed, + control, + progress, + progress_epoch, + interactive, + )) if cached_id == &generation_id => ( + Arc::clone(owners), + Arc::clone(index), + Arc::clone(build), + Arc::clone(failed), + control.clone(), + Arc::clone(progress), + *progress_epoch, + Arc::clone(interactive), + ), _ => { + if let Some((_, _, _, _, _, control, _, _, _)) = cached.as_ref() { + control.retire(); + } let owners = Arc::new(OnceLock::new()); let index = Arc::new(OnceLock::new()); let build = Arc::new(Mutex::new(None)); let failed = Arc::new(AtomicBool::new(false)); + let control = GenerationTextControlV1::new(Arc::clone(&self.shutting_down)); + let progress = Arc::new(Mutex::new(CodeIndexBuildProgressStateV1::new())); let graph_activation = Arc::new(RwLock::new(CodeGraphActivationStateV1::Pending)); + let progress_epoch = hotpath::measure_block!("query.artifact.progress.publish", { + self.build_progress + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .replace_generation(generation_id.clone()) + }); *cached = Some(( generation_id, Arc::clone(&owners), Arc::clone(&index), Arc::clone(&build), Arc::clone(&failed), + control.clone(), + Arc::clone(&progress), + progress_epoch, Arc::clone(&graph_activation), )); - (owners, index, build, failed, graph_activation) + ( + owners, + index, + build, + failed, + control, + progress, + progress_epoch, + graph_activation, + ) } }; + let metadata = Arc::new( + VerifiedSealedTextGenerationMetadataV1::from_published_generation(&generation), + ); LatestCompleteCodeIndexV1 { generation, - query_owners, + text: LatestCodeTextGenerationV1 { + metadata, + query_owners, + text_projection_build, + text_projection_failed, + text_control, + text_progress_state, + text_progress_slot: Arc::clone(&self.build_progress), + text_progress_owner_epoch, + text_progress_daemon_incarnation: self.progress_daemon_incarnation, + text_progress_producer_incarnation: self.progress_producer_incarnation, + text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( + &self.store_root, + &self.publication, + &self.resident_memory, + &self.project_id, + &self.worktree_id, + ), + preopened_source: Arc::new(Mutex::new(None)), + publication_binding: None, + }, record_index, - text_projection_build, - text_projection_failed, - text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( - &self.store_root, - &self.publication, - &self.resident_memory, - &self.project_id, - &self.worktree_id, - ), - text_control_epoch: Arc::clone(&self.epoch), - text_control_shutdown: Arc::clone(&self.shutting_down), graph_activation, } } @@ -3758,24 +5222,49 @@ impl CodeIndexWorktreeSchedulerV1 { .map(|generation| { generation .filter(|generation| self.validate_generation_identity(generation).is_ok()) - .map(|generation| LatestCompleteCodeIndexV1 { - generation, - query_owners: Arc::new(OnceLock::new()), - record_index: Arc::new(OnceLock::new()), - text_projection_build: Arc::new(Mutex::new(None)), - text_projection_failed: Arc::new(AtomicBool::new(false)), - text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( - &self.store_root, - &self.publication, - &self.resident_memory, - &self.project_id, - &self.worktree_id, - ), - text_control_epoch: Arc::clone(&self.epoch), - text_control_shutdown: Arc::clone(&self.shutting_down), - graph_activation: Arc::new(RwLock::new( - CodeGraphActivationStateV1::Pending, - )), + .map(|generation| { + let generation_id = generation.manifest().generation_id.clone(); + let mut progress_slot = CodeIndexBuildProgressSlotStateV1::default(); + let text_progress_owner_epoch = + progress_slot.replace_generation(generation_id); + let metadata = Arc::new( + VerifiedSealedTextGenerationMetadataV1::from_published_generation( + &generation, + ), + ); + LatestCompleteCodeIndexV1 { + generation, + text: LatestCodeTextGenerationV1 { + metadata, + query_owners: Arc::new(OnceLock::new()), + text_projection_build: Arc::new(Mutex::new(None)), + text_projection_failed: Arc::new(AtomicBool::new(false)), + text_control: GenerationTextControlV1::new(Arc::clone( + &self.shutting_down, + )), + text_progress_state: Arc::new(Mutex::new( + CodeIndexBuildProgressStateV1::new(), + )), + text_progress_slot: Arc::new(RwLock::new(progress_slot)), + text_progress_owner_epoch, + text_progress_daemon_incarnation: self.progress_daemon_incarnation, + text_progress_producer_incarnation: self + .progress_producer_incarnation, + text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( + &self.store_root, + &self.publication, + &self.resident_memory, + &self.project_id, + &self.worktree_id, + ), + preopened_source: Arc::new(Mutex::new(None)), + publication_binding: None, + }, + record_index: Arc::new(OnceLock::new()), + graph_activation: Arc::new(RwLock::new( + CodeGraphActivationStateV1::Pending, + )), + } }) }) .map_err(|error| CodeIndexProductionErrorV1::Publication(error).into()) @@ -3809,7 +5298,7 @@ impl CodeIndexWorktreeSchedulerV1 { .ignored_source_admissions .iter() .any(|admission| admission.logical_path == logical_path); - self.capture_admitted_candidate(registry, logical_path, control, explicitly_admitted) + self.capture_admitted_candidate(registry, logical_path, control, None, explicitly_admitted) } fn ignored_admission_paths(&self) -> BTreeSet<&str> { @@ -3824,6 +5313,7 @@ impl CodeIndexWorktreeSchedulerV1 { registry: &StaticLanguageRegistry, logical_path: &str, control: Option<&dyn CodeIndexExecutionControlV1>, + progress: Option<&git_tree_capture::CaptureProgressV1>, explicitly_admitted: bool, ) -> Result, CodeIndexSchedulerErrorV1> { if !explicitly_admitted && crate::config::is_generated_path_segment(logical_path) { @@ -3843,12 +5333,28 @@ impl CodeIndexWorktreeSchedulerV1 { ignored_dependencies::read_bounded_snapshot_source(&absolute, control)? }; ignored_dependencies::checkpoint_if_present(control)?; - self.capture_candidate_bytes(registry, logical_path, &raw_bytes) + self.capture_candidate_bytes_with_progress(registry, logical_path, &raw_bytes, progress) } + #[hotpath::measure(label = "code_index.capture.authoritative_snapshot")] fn capture_authoritative_snapshot( &self, control: Option<&dyn CodeIndexExecutionControlV1>, + ) -> Result { + self.capture_authoritative_snapshot_with_active_generation_reuse(control, true) + } + + fn capture_authoritative_snapshot_without_active_generation_reuse( + &self, + control: Option<&dyn CodeIndexExecutionControlV1>, + ) -> Result { + self.capture_authoritative_snapshot_with_active_generation_reuse(control, false) + } + + fn capture_authoritative_snapshot_with_active_generation_reuse( + &self, + control: Option<&dyn CodeIndexExecutionControlV1>, + allow_active_generation_reuse: bool, ) -> Result { if self.shutting_down.load(Ordering::Acquire) { return Err(cancelled_code_index_reconcile()); @@ -3866,7 +5372,8 @@ impl CodeIndexWorktreeSchedulerV1 { if self.shutting_down.load(Ordering::Acquire) { return Err(cancelled_code_index_reconcile()); } - if self.ignored_source_admissions.is_empty() + if allow_active_generation_reuse + && self.ignored_source_admissions.is_empty() && classification.changes().is_empty() && let (Some(reference), Some(revision), Some(tree)) = ( self.identity.head_ref(), @@ -3937,6 +5444,7 @@ impl CodeIndexWorktreeSchedulerV1 { // so the captured snapshot is byte-identical to the sequential sweep. let candidates = candidate_paths.into_iter().collect::>(); let admitted_paths = self.ignored_admission_paths(); + let progress = git_tree_capture::CaptureProgressV1::new(); let outcomes = crate::code_index::parallelism::install(|| { use rayon::prelude::*; candidates @@ -3951,6 +5459,7 @@ impl CodeIndexWorktreeSchedulerV1 { ®istry, logical_path, control, + Some(&progress), admitted_paths.contains(logical_path.as_str()), ) }) diff --git a/src/daemon/code_index_scheduler/git_tree_capture.rs b/src/daemon/code_index_scheduler/git_tree_capture.rs index 074a86c9eb..b872ea78b8 100644 --- a/src/daemon/code_index_scheduler/git_tree_capture.rs +++ b/src/daemon/code_index_scheduler/git_tree_capture.rs @@ -3,6 +3,8 @@ use std::collections::BTreeSet; use std::path::Path; use std::sync::Arc; +#[cfg(feature = "hotpath")] +use std::sync::atomic::AtomicU64; use gix::bstr::ByteSlice; use tracedecay_code_index::production::CodeIndexExecutionControlV1; @@ -52,6 +54,127 @@ pub(super) fn classify_capture_failure( /// log flood. const MAX_REPORTED_WITHHELD_SOURCES: usize = 16; +/// Publish capture progress at a coarse cadence so a large tree does not +/// turn one file into one profiler event. The final values are always flushed +/// by `Drop`, including cancellation and other early-return paths. +#[cfg(feature = "hotpath")] +const CAPTURE_PROGRESS_UPDATE_PERIOD: u64 = 32; + +pub(super) struct CaptureProgressV1 { + #[cfg(feature = "hotpath")] + candidate_files: AtomicU64, + #[cfg(feature = "hotpath")] + candidate_bytes: AtomicU64, + #[cfg(feature = "hotpath")] + processed_files: AtomicU64, + #[cfg(feature = "hotpath")] + processed_bytes: AtomicU64, + #[cfg(feature = "hotpath")] + captured_files: AtomicU64, + #[cfg(feature = "hotpath")] + captured_bytes: AtomicU64, +} + +impl CaptureProgressV1 { + pub(super) const fn new() -> Self { + Self { + #[cfg(feature = "hotpath")] + candidate_files: AtomicU64::new(0), + #[cfg(feature = "hotpath")] + candidate_bytes: AtomicU64::new(0), + #[cfg(feature = "hotpath")] + processed_files: AtomicU64::new(0), + #[cfg(feature = "hotpath")] + processed_bytes: AtomicU64::new(0), + #[cfg(feature = "hotpath")] + captured_files: AtomicU64::new(0), + #[cfg(feature = "hotpath")] + captured_bytes: AtomicU64::new(0), + } + } + + #[inline] + pub(super) fn observe_candidate(&self, bytes: usize) { + #[cfg(feature = "hotpath")] + { + let candidate_files = self + .candidate_files + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1); + self.candidate_bytes + .fetch_add(bytes as u64, Ordering::Relaxed); + self.publish_at_cadence(candidate_files, false); + } + #[cfg(not(feature = "hotpath"))] + let _ = bytes; + } + + #[inline] + pub(super) fn observe_processed(&self, bytes: usize) { + #[cfg(feature = "hotpath")] + { + let processed_files = self + .processed_files + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1); + self.processed_bytes + .fetch_add(bytes as u64, Ordering::Relaxed); + self.publish_at_cadence(processed_files, false); + } + #[cfg(not(feature = "hotpath"))] + let _ = bytes; + } + + #[inline] + pub(super) fn observe_captured(&self, bytes: usize) { + #[cfg(feature = "hotpath")] + { + let captured_files = self + .captured_files + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1); + self.captured_bytes + .fetch_add(bytes as u64, Ordering::Relaxed); + self.publish_at_cadence(captured_files, false); + } + #[cfg(not(feature = "hotpath"))] + let _ = bytes; + } + + #[cfg(feature = "hotpath")] + #[inline(always)] + fn publish_at_cadence(&self, observation_count: u64, force: bool) { + if !force && !Self::cadence_is_due(observation_count) { + return; + } + let candidate_files = self.candidate_files.load(Ordering::Relaxed); + let processed_files = self.processed_files.load(Ordering::Relaxed); + let captured_files = self.captured_files.load(Ordering::Relaxed); + hotpath::gauge!("code_index.capture.candidate_files").set(candidate_files); + hotpath::gauge!("code_index.capture.candidate_bytes") + .set(self.candidate_bytes.load(Ordering::Relaxed)); + hotpath::gauge!("code_index.capture.processed_files").set(processed_files); + hotpath::gauge!("code_index.capture.processed_bytes") + .set(self.processed_bytes.load(Ordering::Relaxed)); + hotpath::gauge!("code_index.capture.captured_files").set(captured_files); + hotpath::gauge!("code_index.capture.captured_bytes") + .set(self.captured_bytes.load(Ordering::Relaxed)); + } + + #[cfg(feature = "hotpath")] + #[inline(always)] + fn cadence_is_due(observation_count: u64) -> bool { + observation_count != 0 && observation_count.is_multiple_of(CAPTURE_PROGRESS_UPDATE_PERIOD) + } +} + +impl Drop for CaptureProgressV1 { + fn drop(&mut self) { + #[cfg(feature = "hotpath")] + self.publish_at_cadence(0, true); + } +} + pub(super) fn report_withheld_sources(withheld: &[WithheldSourceV1]) { if withheld.is_empty() { return; @@ -160,54 +283,69 @@ impl DaemonCodeIndexPublicationStoreV1 { } impl CodeIndexWorktreeSchedulerV1 { - pub(super) fn capture_candidate_bytes( + pub(super) fn capture_candidate_bytes_with_progress( &self, registry: &StaticLanguageRegistry, logical_path: &str, raw_bytes: &[u8], + progress: Option<&CaptureProgressV1>, ) -> Result, CodeIndexSchedulerErrorV1> { if self.shutting_down.load(Ordering::Acquire) { return Err(cancelled_code_index_reconcile()); } - let Some(extension) = Path::new(logical_path) - .extension() - .and_then(|value| value.to_str()) - else { - return Ok(None); - }; - let Some(descriptor) = registry.descriptor_for_extension(&extension.to_lowercase()) else { - return Ok(None); - }; - let (sanitized_bytes, sensitivity_level, receipt_id) = - privacy::sanitize_code_file(&descriptor.language, raw_bytes)?; - let (digest, shared) = self.byte_pool.intern(sanitized_bytes); - let retained_reservation = self.reserve_snapshot_memory(&digest, shared.len())?; - let occurrence = file_occurrence_id( - &self.repository_id, - &self.worktree_id, - logical_path, - &digest, - &receipt_id, - )?; - Ok(Some(CapturedCandidateV1 { - file: SanitizedCodeFileV1 { - file_occurrence_id: occurrence.clone(), - logical_path: logical_path.to_owned(), - language: Some(descriptor.language.clone()), - content_digest: digest, - disposition: SnapshotFileDispositionV1::Present, - }, - captured: CodeIndexCapturedFileV1 { - file_occurrence_id: occurrence, - sanitized_bytes: shared.to_vec(), - sensitivity_level, - }, - receipt_id, - retained: shared, - retained_reservation, - })) + if let Some(progress) = progress { + progress.observe_candidate(raw_bytes.len()); + } + let result: Result, CodeIndexSchedulerErrorV1> = (|| { + let Some(extension) = Path::new(logical_path) + .extension() + .and_then(|value| value.to_str()) + else { + return Ok(None); + }; + let Some(descriptor) = registry.descriptor_for_extension(&extension.to_lowercase()) + else { + return Ok(None); + }; + let (sanitized_bytes, sensitivity_level, receipt_id) = + privacy::sanitize_code_file(&descriptor.language, raw_bytes)?; + let (digest, shared) = self.byte_pool.intern(sanitized_bytes); + let retained_reservation = self.reserve_snapshot_memory(&digest, shared.len())?; + let occurrence = file_occurrence_id( + &self.repository_id, + &self.worktree_id, + logical_path, + &digest, + &receipt_id, + )?; + Ok(Some(CapturedCandidateV1 { + file: SanitizedCodeFileV1 { + file_occurrence_id: occurrence.clone(), + logical_path: logical_path.to_owned(), + language: Some(descriptor.language.clone()), + content_digest: digest, + disposition: SnapshotFileDispositionV1::Present, + }, + captured: CodeIndexCapturedFileV1 { + file_occurrence_id: occurrence, + sanitized_bytes: Arc::clone(&shared), + sensitivity_level, + }, + receipt_id, + retained: shared, + retained_reservation, + })) + })(); + if let Some(progress) = progress { + progress.observe_processed(raw_bytes.len()); + if let Ok(Some(candidate)) = result.as_ref() { + progress.observe_captured(candidate.captured.sanitized_bytes.len()); + } + } + result } + #[hotpath::measure(label = "code_index.capture.exact_git_tree")] pub(super) fn capture_exact_git_tree_snapshot( &self, source: &ExactGitTreeSourceV1, @@ -254,6 +392,7 @@ impl CodeIndexWorktreeSchedulerV1 { entries.sort_by(|left, right| left.filepath.cmp(&right.filepath)); let registry = StaticLanguageRegistry::new(); + let progress = CaptureProgressV1::new(); let mut files = Vec::new(); let mut captured_files = Vec::new(); let mut sanitization_receipts = BTreeSet::new(); @@ -273,8 +412,12 @@ impl CodeIndexWorktreeSchedulerV1 { let blob = repository .find_blob(entry.oid) .map_err(|_| CodeIndexSearchUnavailableReasonV1::GenerationUnavailable)?; - let candidate = match self.capture_candidate_bytes(®istry, &logical_path, &blob.data) - { + let candidate = match self.capture_candidate_bytes_with_progress( + ®istry, + &logical_path, + &blob.data, + Some(&progress), + ) { Ok(Some(candidate)) => candidate, Ok(None) => continue, Err(error) => { @@ -457,6 +600,43 @@ mod tests { branch_generations, classify_capture_failure, }; + #[cfg(feature = "hotpath")] + use super::{CAPTURE_PROGRESS_UPDATE_PERIOD, CaptureProgressV1}; + + #[cfg(feature = "hotpath")] + #[test] + fn zero_captured_large_tree_uses_bounded_candidate_cadence() { + let candidate_count = CAPTURE_PROGRESS_UPDATE_PERIOD * 64; + let progress = CaptureProgressV1::new(); + for _ in 0..candidate_count { + progress.observe_candidate(1); + } + assert_eq!( + progress + .candidate_files + .load(std::sync::atomic::Ordering::Relaxed), + candidate_count + ); + assert_eq!( + progress + .captured_files + .load(std::sync::atomic::Ordering::Relaxed), + 0 + ); + + let publish_points = (1..=candidate_count) + .filter(|count| CaptureProgressV1::cadence_is_due(*count)) + .collect::>(); + + assert_eq!(publish_points.len(), 64); + assert_eq!( + publish_points.first().copied(), + Some(CAPTURE_PROGRESS_UPDATE_PERIOD) + ); + assert_eq!(publish_points.last().copied(), Some(candidate_count)); + assert!(!CaptureProgressV1::cadence_is_due(0)); + } + fn git(root: &Path, arguments: &[&str]) { let status = Command::new(crate::git::git_program()) .current_dir(root) @@ -674,6 +854,36 @@ mod tests { ); } + #[test] + fn exact_git_capture_reuses_one_byte_owner_for_snapshot_and_production() { + let (project, _store, scheduler) = generated_source_fixture(); + let revision = git_output(project.path(), &["rev-parse", "HEAD"]); + let tree = git_output(project.path(), &["rev-parse", "HEAD^{tree}"]); + + let captured = scheduler + .capture_exact_git_tree_snapshot( + &ExactGitTreeSourceV1 { + reference: tracedecay_domain::RefId::new("refs/heads/main").expect("reference"), + revision: tracedecay_domain::CommitId::new(revision).expect("revision"), + tree: tracedecay_domain::TreeId::new(tree).expect("tree"), + }, + &branch_generations::BranchGenerationReadControlV1 { + deadline: None, + cancellation: None, + }, + ) + .expect("capture exact Git tree"); + + assert_eq!(captured.captured_files.len(), captured.retained_bytes.len()); + assert!( + captured.captured_files.iter().all(|captured_file| captured + .retained_bytes + .iter() + .any(|retained| Arc::ptr_eq(&captured_file.sanitized_bytes, retained))), + "production input must retain the snapshot's canonical byte allocation" + ); + } + /// The reference is provenance, and provenance a repository cannot vouch /// for is refused: a capture may resolve any commit in the object database, /// but never stamp it with a branch name that does not exist. diff --git a/src/daemon/code_index_scheduler/memory_tests.rs b/src/daemon/code_index_scheduler/memory_tests.rs index 9771577652..1dc8b1ef31 100644 --- a/src/daemon/code_index_scheduler/memory_tests.rs +++ b/src/daemon/code_index_scheduler/memory_tests.rs @@ -83,13 +83,13 @@ fn captured_source_bytes_are_charged_until_the_snapshot_drops() { .map(|bytes| bytes.len() as u64) .sum::(); assert!(retained_bytes > 0); - assert_eq!(authority.snapshot().used_bytes, retained_bytes * 2); + assert_eq!(authority.snapshot().used_bytes, retained_bytes); drop(captured); assert_eq!(authority.snapshot().used_bytes, 0); } #[test] -fn completed_reconcile_releases_the_captured_file_copy_charge() { +fn completed_reconcile_retains_the_canonical_snapshot_charge() { let project = fixture(); let store = TempDir::new().expect("store root"); let mut scheduler = CodeIndexWorktreeSchedulerV1::open( @@ -122,7 +122,7 @@ fn completed_reconcile_releases_the_captured_file_copy_charge() { assert_eq!( authority.snapshot().used_bytes, retained_bytes, - "the no-build path must drop captured Vec copies before retaining only the Arc charge" + "the no-build path retains only the canonical Arc source charge" ); } diff --git a/src/daemon/code_index_scheduler/observability.rs b/src/daemon/code_index_scheduler/observability.rs index edeff5d7b8..660c872201 100644 --- a/src/daemon/code_index_scheduler/observability.rs +++ b/src/daemon/code_index_scheduler/observability.rs @@ -11,49 +11,50 @@ use tracedecay_domain::{ use tracedecay_query::retrieval::AuthorizedQueryFallbackV1; use tracedecay_query::retrieval::observation::observe_composition; use tracedecay_usecases::observability::{ - BoundedObservabilityProducerV1, emit_retrieval_pipeline, record_index, + BoundedObservabilityProducerV1, ObservabilityEmissionOutcomeV1, emit_index, + emit_retrieval_pipeline, }; use super::CodeIndexReconcileOutcomeV1; /// Project-bound observation authority installed once per mounted worktree /// (`CodeIndexSchedulerRegistryV1::install_index_observability`). The session -/// database carries index lifecycle receipts directly; the bounded producer -/// carries the retrieval-pipeline families off the query hot path. +/// bounded producer carries lifecycle and retrieval-pipeline observations off +/// the scheduler and query hot paths. #[derive(Clone)] pub(in crate::daemon) struct CodeIndexObservabilityV1 { - session_db: crate::global_db::RegisteredGlobalDbLeaseV1, producer: Arc, } impl CodeIndexObservabilityV1 { - pub(in crate::daemon) fn new( - session_db: crate::global_db::RegisteredGlobalDbLeaseV1, - producer: Arc, - ) -> Self { - Self { - session_db, - producer, - } + pub(in crate::daemon) fn new(producer: Arc) -> Self { + Self { producer } } /// Records one terminal reconcile pass as a canonical index lifecycle /// observation beside the worker's in-memory cadence receipt. - pub(in crate::daemon) async fn record_reconcile_outcome( + pub(in crate::daemon) fn record_reconcile_outcome( &self, outcome: &CodeIndexReconcileOutcomeV1, service_micros: u64, queue_depth_bucket: QueueDepthBucketV1, ) { let observation = reconcile_index_observation(outcome, service_micros, queue_depth_bucket); - if let Err(error) = record_index(self.session_db.as_ref(), observation).await { - tracing::debug!( + match emit_index(self.producer.as_ref(), observation) { + Ok(ObservabilityEmissionOutcomeV1::Enqueued) => {} + Ok(ObservabilityEmissionOutcomeV1::DroppedAtCapacity) => tracing::debug!( + event = "code_index_observability", + family = "index", + outcome = "dropped_at_capacity", + "code-index lifecycle observation was refused by the bounded producer" + ), + Err(error) => tracing::debug!( event = "code_index_observability", family = "index", outcome = "unavailable", - error = ?error, - "code-index lifecycle observation could not be recorded" - ); + error, + "code-index lifecycle observation could not be enqueued" + ), } } diff --git a/src/daemon/code_index_scheduler/queries.rs b/src/daemon/code_index_scheduler/queries.rs index 754f4c81b5..0f95fa6203 100644 --- a/src/daemon/code_index_scheduler/queries.rs +++ b/src/daemon/code_index_scheduler/queries.rs @@ -39,7 +39,8 @@ use tracedecay_domain::{ use tracedecay_tool_catalog::SortContractId; use super::{ - CodeIndexSchedulerRegistryV1, DaemonCodeIndexPublicationStoreV1, LatestCompleteCodeIndexV1, + CodeIndexSchedulerRegistryV1, DaemonCodeIndexPublicationStoreV1, LatestCodeTextGenerationV1, + LatestCompleteCodeIndexV1, ProductionCodeIndexQueryOwnersV1, registry::{UniqueMountedWorktree, latest_matches_scope_identity, unique_mounted_for_scope}, }; use tracedecay_query::code_search; @@ -376,6 +377,76 @@ impl CodeIndexSchedulerRegistryV1 { } Ok(latest) } + + async fn resolve_text_serving_generation( + &self, + request: &RequestContext, + requested: &CodeGenerationId, + page: &tracedecay_application::PageRequest, + authority: &tracedecay_query::retrieval::QueryAuthorityV1, + routing: &PreparedQueryRoutingBindingsV1, + ) -> Result { + let wait = remaining_generation_resolution_wait(request) + .ok_or(CallableCodeCursorError::Unavailable)?; + let resolution = async { + if let Some(cursor) = page.cursor.as_ref() { + let expected_generation = (!is_unpinned_latest(requested)).then_some(requested); + let scope = request.scope().clone(); + route_authenticated_prepared_query_cursor( + authority, + routing, + cursor.as_str(), + current_utc_micros()?, + expected_generation, + |generation| async move { + if let Some(latest) = self + .latest_text_serving_for_scope(&scope) + .await + .filter(|latest| { + latest.metadata().manifest().generation_id == generation + }) + { + return Ok::<_, code_search::CodeIndexSearchUnavailableReasonV1>(Some( + latest, + )); + } + self.generation_for(&scope, &generation) + .await + .map(|latest| latest.map(|latest| latest.text_generation_handle())) + }, + )? + .await + .map_err(|_| CallableCodeCursorError::Unavailable)? + .ok_or(CallableCodeCursorError::Unavailable) + } else if is_unpinned_latest(requested) { + self.latest_text_fresh_for_scope(request.scope()) + .await + .ok_or(CallableCodeCursorError::Unavailable) + } else if let Some(latest) = self + .latest_text_serving_for_scope(request.scope()) + .await + .filter(|latest| latest.metadata().manifest().generation_id == *requested) + { + Ok(latest) + } else { + self.generation_for(request.scope(), requested) + .await + .map_err(|_| CallableCodeCursorError::Unavailable)? + .map(|latest| latest.text_generation_handle()) + .ok_or(CallableCodeCursorError::Unavailable) + } + }; + let latest = tokio::time::timeout(wait, resolution) + .await + .map_err(|_| CallableCodeCursorError::Unavailable)??; + if !matches!( + request.admission_at(current_utc_micros()?), + RequestAdmission::Admitted + ) { + return Err(CallableCodeCursorError::Unavailable); + } + Ok(latest) + } } fn typed(value: impl Into) -> Result @@ -480,6 +551,41 @@ fn base_request( }) } +fn text_base_request( + context: &RetrievalPortContext<'_>, + latest: &LatestCodeTextGenerationV1, + temporal_mode: TemporalModeV1, + profile: &tracedecay_domain::FusionProfile, +) -> Result { + let manifest = latest.metadata().manifest(); + let snapshot = latest.metadata().snapshot(); + Ok(RetrievalRequest { + principal: typed::(context.request.actor().to_string())?, + scope: RetrievalScope { + privacy_domain: manifest.privacy_domain.clone(), + root: SingleRootScopeV1 { + repository: snapshot.repository.clone(), + worktree: snapshot.worktree.clone(), + reference: snapshot.reference.clone(), + }, + }, + temporal_mode, + snapshot: RetrievalSnapshot { + watermarks: VectorWatermark::default(), + freshness_digest: FreshnessVectorDigest::new(manifest.snapshot_digest.as_str()) + .map_err(|error| error.to_string())?, + authorization_revision: AuthorizationRevision::new(format!( + "authorization.grant.{}", + context.request.grant().revision + )) + .map_err(|error| error.to_string())?, + captured_at: manifest.seal.sealed_at, + }, + profile_id: profile.profile_id.clone(), + budget: profile.retrieval_budget, + }) +} + fn unavailable(finished_at: tracedecay_domain::UtcMicros) -> RetrievalPortOutcome { RetrievalPortOutcome::Unavailable(RetrievalEvidence { payload: None, @@ -996,6 +1102,42 @@ impl NativeRecordReadPortV1 for LatestCompleteNativeRecordReadPortV1<'_> { } } +struct TextArtifactNativeRecordReadPortV1 { + generation: CodeGenerationId, + owners: std::sync::Arc, +} + +impl NativeRecordReadPortV1 for TextArtifactNativeRecordReadPortV1 { + fn generation(&self) -> &CodeGenerationId { + &self.generation + } + + fn occurrence( + &self, + binding: &CodeCandidateBindingV1, + ) -> Result { + if &binding.occurrence.generation != self.generation() { + return Err(QueryExecutionContractErrorV1::GenerationMismatch); + } + self.owners.occurrence_by_binding(binding) + } + + fn occurrence_by_chunk( + &self, + chunk_id: &CodeSearchChunkId, + ) -> Result { + self.owners.occurrence_by_chunk(chunk_id) + } + + fn symbol( + &self, + _symbol: &SymbolOccurrenceId, + _file: &FileOccurrenceId, + ) -> Result { + Err(QueryExecutionContractErrorV1::RecordUnavailable) + } +} + fn application_occurrence(record: NativeCodeOccurrenceV1) -> CodeOccurrenceRecord { CodeOccurrenceRecord { file: record.file, @@ -1110,6 +1252,36 @@ struct PreparedCallableQueryV1 { query: PreparedQueryV1, } +struct PreparedTextCallableQueryV1 { + latest: LatestCodeTextGenerationV1, + query: PreparedQueryV1, +} + +trait PreparedCallableQueryStateV1 { + fn generation(&self) -> &CodeGenerationId; + fn query(&self) -> &PreparedQueryV1; +} + +impl PreparedCallableQueryStateV1 for PreparedCallableQueryV1 { + fn generation(&self) -> &CodeGenerationId { + &self.latest.generation.manifest().generation_id + } + + fn query(&self) -> &PreparedQueryV1 { + &self.query + } +} + +impl PreparedCallableQueryStateV1 for PreparedTextCallableQueryV1 { + fn generation(&self) -> &CodeGenerationId { + &self.latest.metadata().manifest().generation_id + } + + fn query(&self) -> &PreparedQueryV1 { + &self.query + } +} + macro_rules! prepare_callable_query_or_return { ($registry:expr, $context:expr, $request:expr, $operation:expr, $binding:expr) => {{ let Ok(query_binding_digest) = canonical_sha256(&$binding) else { @@ -1138,6 +1310,34 @@ macro_rules! prepare_callable_query_or_return { }}; } +macro_rules! prepare_text_callable_query_or_return { + ($registry:expr, $context:expr, $request:expr, $operation:expr, $binding:expr) => {{ + let Ok(query_binding_digest) = canonical_sha256(&$binding) else { + return unavailable(query_finished_at()); + }; + match $registry + .prepare_text_callable_query( + &$context, + &$request.scope.generation, + &$request.meta.page, + $request.meta.temporal, + $operation, + query_binding_digest.clone(), + ) + .await + { + Ok(prepared) => (prepared, query_binding_digest), + Err(error) => { + return rejected_cursor( + query_finished_at(), + $request.scope.generation.clone(), + error, + ); + } + } + }}; +} + /// Resolves the traversal start symbol a relation query is anchored on. /// /// A node id that is not a symbol occurrence is unavailable outright; one that @@ -1198,6 +1398,45 @@ impl CodeIndexSchedulerRegistryV1 { )?; Ok(PreparedCallableQueryV1 { latest, query }) } + + async fn prepare_text_callable_query( + &self, + context: &RetrievalPortContext<'_>, + generation: &CodeGenerationId, + page: &tracedecay_application::PageRequest, + temporal: TemporalModeV1, + operation: &'static str, + query_binding_digest: ManifestDigest, + ) -> Result { + let authority = self + .query_authority_for_scope(context.request.scope()) + .await + .ok_or(CallableCodeCursorError::Unavailable)?; + let routing = prepared_routing_bindings( + context, + temporal, + operation, + query_binding_digest, + page.page_size, + )?; + let latest = self + .resolve_text_serving_generation( + context.request, + generation, + page, + authority.as_ref(), + &routing, + ) + .await?; + let base = text_base_request(context, &latest, temporal, authority.profile()) + .map_err(|_| CallableCodeCursorError::Unavailable)?; + let query = PreparedQueryV1::prepare( + authority, + base, + page.cursor.as_ref().map(OpaqueCursor::as_str), + )?; + Ok(PreparedTextCallableQueryV1 { latest, query }) + } } #[allow(clippy::too_many_arguments)] @@ -1270,7 +1509,7 @@ fn finish_generation_page( #[allow(clippy::too_many_arguments)] fn finish_query_with_coverage( - prepared: &PreparedCallableQueryV1, + prepared: &impl PreparedCallableQueryStateV1, context: &RetrievalPortContext<'_>, operation: &'static str, query_binding_digest: ManifestDigest, @@ -1279,7 +1518,7 @@ fn finish_query_with_coverage( coverage: tracedecay_domain::RetrieverCoverage, ) -> RetrievalPortOutcome> { let finished_at = query_finished_at(); - let generation = prepared.latest.generation.manifest().generation_id.clone(); + let generation = prepared.generation().clone(); let bindings = PreparedQueryBindingsV1::new( operation, context.request.scope().scope_digest.clone(), @@ -1288,7 +1527,7 @@ fn finish_query_with_coverage( ); let pagination = bindings.and_then(|bindings| { prepared - .query + .query() .paginate(&bindings, page.items, requested_page.page_size, finished_at) }); match pagination { @@ -1426,7 +1665,7 @@ fn terminal_lane_evidence( #[allow(clippy::too_many_arguments)] fn finish_native_lane_page( - prepared: &PreparedCallableQueryV1, + prepared: &impl PreparedCallableQueryStateV1, context: &RetrievalPortContext<'_>, operation: &'static str, query_binding_digest: ManifestDigest, @@ -1495,7 +1734,7 @@ fn mark_lane_partial( #[allow(clippy::too_many_arguments)] fn finish_native_lane_query( - prepared: &PreparedCallableQueryV1, + prepared: &impl PreparedCallableQueryStateV1, context: &RetrievalPortContext<'_>, operation: &'static str, query_binding_digest: ManifestDigest, @@ -1533,11 +1772,8 @@ where } NativeLaneOutcomeV1::Unavailable(reason) => { let omission = retrieval_failure_omission(&reason); - let evidence = terminal_lane_evidence( - finished_at, - prepared.latest.generation.manifest().generation_id.clone(), - omission, - ); + let evidence = + terminal_lane_evidence(finished_at, prepared.generation().clone(), omission); match reason { RetrievalFailure::InvalidRequest { .. } | RetrievalFailure::Internal { .. } => { RetrievalPortOutcome::Failed(evidence) @@ -1549,18 +1785,18 @@ where } NativeLaneOutcomeV1::Denied => RetrievalPortOutcome::Unavailable(terminal_lane_evidence( finished_at, - prepared.latest.generation.manifest().generation_id.clone(), + prepared.generation().clone(), OmissionReason::Unavailable, )), NativeLaneOutcomeV1::Stale(_) => RetrievalPortOutcome::Unavailable(terminal_lane_evidence( finished_at, - prepared.latest.generation.manifest().generation_id.clone(), + prepared.generation().clone(), OmissionReason::Stale, )), NativeLaneOutcomeV1::BudgetExceeded(usage) => { let mut evidence = terminal_lane_evidence( finished_at, - prepared.latest.generation.manifest().generation_id.clone(), + prepared.generation().clone(), OmissionReason::Budget, ); evidence.budget = application_budget_usage(usage); @@ -1569,7 +1805,7 @@ where NativeLaneOutcomeV1::TimedOut(usage) => { let mut evidence = terminal_lane_evidence( finished_at, - prepared.latest.generation.manifest().generation_id.clone(), + prepared.generation().clone(), OmissionReason::TimedOut, ); evidence.budget = application_budget_usage(usage); @@ -1578,7 +1814,7 @@ where NativeLaneOutcomeV1::Cancelled => { let mut evidence = terminal_lane_evidence( finished_at, - prepared.latest.generation.manifest().generation_id.clone(), + prepared.generation().clone(), OmissionReason::Cancelled, ); evidence.cancellation = Some(CancellationObservation { @@ -1610,7 +1846,7 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { request: &'a ExactOccurrenceRequest, ) -> CallableCodeQueryFuture<'a, ExactOccurrenceRecord> { Box::pin(async move { - let (prepared, query_binding_digest) = prepare_callable_query_or_return!( + let (prepared, query_binding_digest) = prepare_text_callable_query_or_return!( self, context, request, @@ -1625,7 +1861,7 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { ) ); let latest = &prepared.latest; - let served_generation = latest.generation.manifest().generation_id.clone(); + let served_generation = latest.metadata().manifest().generation_id.clone(); let finished_at = query_finished_at(); let base = prepared.query.request(); let Ok(query_view) = tracedecay_domain::EphemeralSanitizedQueryViewV1::sanitize( @@ -1651,7 +1887,10 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { let Ok(owners) = latest.production_query_owners_with_budget(&base.budget) else { return unavailable(finished_at); }; - let records = LatestCompleteNativeRecordReadPortV1 { latest }; + let records = TextArtifactNativeRecordReadPortV1 { + generation: served_generation.clone(), + owners: std::sync::Arc::clone(&owners), + }; let Ok(native_context) = AdmittedGenerationContextV1::admit(served_generation.clone(), &records) else { @@ -1688,7 +1927,7 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { request: &'a PhraseSearchRequest, ) -> CallableCodeQueryFuture<'a, LexicalOccurrenceRecord> { Box::pin(async move { - let (prepared, query_binding_digest) = prepare_callable_query_or_return!( + let (prepared, query_binding_digest) = prepare_text_callable_query_or_return!( self, context, request, @@ -1705,7 +1944,7 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { ) ); let latest = &prepared.latest; - let served_generation = latest.generation.manifest().generation_id.clone(); + let served_generation = latest.metadata().manifest().generation_id.clone(); let finished_at = query_finished_at(); let base = prepared.query.request(); let whole_terms = request @@ -1754,7 +1993,10 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { let Ok(owners) = latest.production_query_owners_with_budget(&base.budget) else { return unavailable(finished_at); }; - let records = LatestCompleteNativeRecordReadPortV1 { latest }; + let records = TextArtifactNativeRecordReadPortV1 { + generation: served_generation.clone(), + owners: std::sync::Arc::clone(&owners), + }; let Ok(native_context) = AdmittedGenerationContextV1::admit(served_generation.clone(), &records) else { diff --git a/src/daemon/code_index_scheduler/query_runtime.rs b/src/daemon/code_index_scheduler/query_runtime.rs index 3d57906c5d..32ace18ff4 100644 --- a/src/daemon/code_index_scheduler/query_runtime.rs +++ b/src/daemon/code_index_scheduler/query_runtime.rs @@ -118,11 +118,18 @@ pub(in crate::daemon) async fn mount_core_query_authority_on_project_open( scope: &ResolvedScope, cursor_keys: &crate::global_db::session_temporal::GlobalDbCursorKeyProvider, ) -> Result<(), QueryRuntimeMountErrorV1> { - let latest = registry - .latest_complete_fresh_for_scope(scope) - .await - .ok_or(QueryRuntimeMountErrorV1::GenerationUnavailable)?; - let privacy_domain = latest.generation.manifest().privacy_domain.clone(); + let privacy_domain = if let Some(text) = registry.latest_text_serving_for_scope(scope).await { + text.metadata().manifest().privacy_domain.clone() + } else { + registry + .latest_complete_fresh_for_scope(scope) + .await + .ok_or(QueryRuntimeMountErrorV1::GenerationUnavailable)? + .generation + .manifest() + .privacy_domain + .clone() + }; let workload: crate::search_eval::CandidateWorkloadV1 = serde_json::from_str(QUERY_FALLBACK_WORKLOAD_JSON) .map_err(|error| QueryRuntimeMountErrorV1::InvalidFallbackPolicy(error.to_string()))?; @@ -231,11 +238,18 @@ pub(in crate::daemon) async fn mount_query_authority_on_project_open( scope: &ResolvedScope, provider: &dyn QueryAuthorityProviderV1, ) -> Result<(), QueryRuntimeMountErrorV1> { - let latest = registry - .latest_complete_fresh_for_scope(scope) - .await - .ok_or(QueryRuntimeMountErrorV1::GenerationUnavailable)?; - let privacy_domain = latest.generation.manifest().privacy_domain.clone(); + let privacy_domain = if let Some(text) = registry.latest_text_serving_for_scope(scope).await { + text.metadata().manifest().privacy_domain.clone() + } else { + registry + .latest_complete_fresh_for_scope(scope) + .await + .ok_or(QueryRuntimeMountErrorV1::GenerationUnavailable)? + .generation + .manifest() + .privacy_domain + .clone() + }; let authority = prepare_query_authority(scope, &privacy_domain, provider)?; registry .mount_query_authority(project_root, scope, authority) @@ -393,26 +407,42 @@ impl CodeIndexSchedulerRegistryV1 { Some(ready) => (ready, false), None => (serving, true), }, - None => match self.latest_complete_ready_for_scope(scope).await { - Some(ready) => (ready, false), - None => { - // Nothing servable and the ready gate refused. Search is the - // one lane whose resolution never runs the freshness ladder, - // so nothing else on this path will ever request the rebuild - // that would remedy the failure — it would return this typed - // error forever. Ask for the remedy exactly once per - // admission (debounced on the pending wake), never inline and - // never parking, then still fail typed rather than degrade - // into an empty answer. - self.request_query_background_reconcile(scope).await; - let unverified = self.generation_is_unverified_for_scope(scope).await; - return Err(if unverified { - QuerySearchExecutionErrorV1::GenerationUnverified - } else { - QuerySearchExecutionErrorV1::GenerationUnavailable - }); + None => { + if let Some((text, current)) = + self.latest_text_serving_freshness_for_scope(scope).await + { + return execute_query_search_on_text( + self, + scope, + input, + text, + None, + !current, + graph_control, + ) + .await; } - }, + match self.latest_complete_ready_for_scope(scope).await { + Some(ready) => (ready, false), + None => { + // Nothing servable and the ready gate refused. Search is the + // one lane whose resolution never runs the freshness ladder, + // so nothing else on this path will ever request the rebuild + // that would remedy the failure — it would return this typed + // error forever. Ask for the remedy exactly once per + // admission (debounced on the pending wake), never inline and + // never parking, then still fail typed rather than degrade + // into an empty answer. + self.request_query_background_reconcile(scope).await; + let unverified = self.generation_is_unverified_for_scope(scope).await; + return Err(if unverified { + QuerySearchExecutionErrorV1::GenerationUnverified + } else { + QuerySearchExecutionErrorV1::GenerationUnavailable + }); + } + } + } }; execute_query_search_on_latest(self, scope, input, latest, served_stale, graph_control) .await @@ -452,6 +482,31 @@ async fn execute_query_search_on_latest( served_stale: bool, graph_control: Arc, ) -> Result +where + C: GraphExecutionControl + 'static, +{ + let text = latest.text_generation_handle(); + execute_query_search_on_text( + schedulers, + scope, + input, + text, + Some(latest), + served_stale, + graph_control, + ) + .await +} + +async fn execute_query_search_on_text( + schedulers: &CodeIndexSchedulerRegistryV1, + scope: &ResolvedScope, + input: QuerySearchExecutionRequestV1, + text: super::LatestCodeTextGenerationV1, + graph_latest: Option, + served_stale: bool, + graph_control: Arc, +) -> Result where C: GraphExecutionControl + 'static, { @@ -459,26 +514,27 @@ where .query_authority_for_scope(scope) .await .ok_or(QuerySearchExecutionErrorV1::AuthorityUnavailable)?; - let generation = latest.generation.manifest().generation_id.clone(); + let metadata = text.metadata(); + let generation = metadata.manifest().generation_id.clone(); let request = RetrievalRequest { principal: input.principal, scope: RetrievalScope { - privacy_domain: latest.generation.manifest().privacy_domain.clone(), + privacy_domain: metadata.manifest().privacy_domain.clone(), root: SingleRootScopeV1 { - repository: latest.generation.snapshot().repository.clone(), - worktree: latest.generation.snapshot().worktree.clone(), - reference: latest.generation.snapshot().reference.clone(), + repository: metadata.snapshot().repository.clone(), + worktree: metadata.snapshot().worktree.clone(), + reference: metadata.snapshot().reference.clone(), }, }, temporal_mode: TemporalModeV1::Current, snapshot: RetrievalSnapshot { watermarks: VectorWatermark::default(), freshness_digest: FreshnessVectorDigest::new( - latest.generation.manifest().snapshot_digest.as_str(), + metadata.manifest().snapshot_digest.as_str(), ) .map_err(|error| QuerySearchExecutionErrorV1::InvalidPolicy(error.to_string()))?, authorization_revision: input.authorization_revision, - captured_at: latest.generation.manifest().seal.sealed_at, + captured_at: metadata.manifest().seal.sealed_at, }, profile_id: authority.profile().profile_id.clone(), budget: authority.profile().retrieval_budget, @@ -487,7 +543,7 @@ where .sanitize(input.sanitizer_revision, input.normalization_revision)?; let request = sanitized.request(); let query_view = sanitized.query_view(); - let owners = latest.production_query_owners_with_budget(&request.budget)?; + let owners = text.production_query_owners_with_budget(&request.budget)?; let parser = CentralExactAdmissionAuthorityV1::new(input.exact_rule_revision); let exact = owners.retrieve_exact(&ExactLaneRequest { base: request.clone(), @@ -515,7 +571,10 @@ where RetrieverOutcome::Unavailable(RetrievalFailure::AuthorityUnavailable { detail: "exact and lexical lanes produced no graph seed".to_owned(), }) - } else if let Ok(graph_serving) = latest.production_graph_serving() { + } else if let Some(graph_serving) = graph_latest + .as_ref() + .and_then(|latest| latest.production_graph_serving().ok()) + { graph_serving.graph.retrieve_graph( &GraphLaneRequest { base: request.clone(), diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 67b8068e34..43b49847e6 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -31,8 +31,8 @@ use super::{ CodeIndexCadenceTriggerV1, CodeIndexEventToReadyReceiptV1, CodeIndexNoopEvidenceV1, CodeIndexPublishEvidenceV1, CodeIndexReconcileOutcomeV1, CodeIndexSchedulerErrorV1, CodeIndexWorktreeSchedulerV1, DaemonCodeIndexControlV1, GenerationDecodeAdmissionV1, - LatestCompleteCodeIndexV1, PendingHintsV1, SharedCodeIndexBytePoolV1, - newly_eligible_percentile, now_micros, + LatestCodeTextGenerationV1, LatestCompleteCodeIndexV1, PendingHintsV1, + SharedCodeIndexBytePoolV1, newly_eligible_percentile, now_micros, }; #[cfg(test)] use super::{CodeIndexBytePoolStatsV1, CodeIndexCadenceReadModelV1}; @@ -46,7 +46,7 @@ mod runtime_generation_census_tests; mod scope_identity; use self::ignored_dependencies::exact_activated_serving_generation; -pub(super) use scope_identity::latest_matches_scope_identity; +pub(super) use scope_identity::{latest_matches_scope_identity, text_matches_scope_identity}; const GENERATION_PUBLICATION_CHANNEL_CAPACITY: usize = 128; const TEXT_PROJECTION_DOCUMENTS_PER_PASS_V1: usize = 64; @@ -361,6 +361,10 @@ pub(super) struct MountedCodeIndexWorktreeV1 { Option>, pub(super) scheduler: Arc>, pub(super) serving_generation: Arc>>, + pub(super) text_generation: Arc>>, + /// Immutable progress snapshot independently readable while the scheduler + /// owns a long reconcile or text-artifact transaction. + pub(super) build_progress: super::CodeIndexBuildProgressSlotV1, /// Monotonic replacement epoch for the serving slot. It invalidates a /// branch-publication token even if a future worker re-seats an equal id. serving_generation_epoch: Arc, @@ -477,6 +481,34 @@ fn dashboard_freshness_identity( identity } +fn dashboard_text_freshness_identity( + latest: Option<&LatestCodeTextGenerationV1>, +) -> crate::dashboard::code_index_freshness_api::CodeIndexWorktreeFreshnessV1 { + let mut identity = + crate::dashboard::code_index_freshness_api::CodeIndexWorktreeFreshnessV1::default(); + if let Some(latest) = latest { + let metadata = latest.metadata(); + let snapshot = metadata.snapshot(); + identity.repository_id = Some(snapshot.repository.as_str().to_owned()); + identity.worktree_id = snapshot + .worktree + .as_ref() + .map(|worktree| worktree.as_str().to_owned()); + identity.source_reference = snapshot + .reference + .as_ref() + .map(|reference| reference.as_str().to_owned()); + identity.source_revision = snapshot + .source_revision + .as_ref() + .map(|revision| revision.as_str().to_owned()); + identity.latest_generation_id = Some(metadata.manifest().generation_id.as_str().to_owned()); + identity.snapshot_content_identity = Some(snapshot.content_identity.as_str().to_owned()); + identity.sealed_at_micros = Some(metadata.manifest().seal.sealed_at.0); + } + identity +} + pub(in crate::daemon) struct CodeIndexSemanticEvaluationPublicationLeaseV1 { _guard: tokio::sync::OwnedMutexGuard<()>, } @@ -571,6 +603,14 @@ impl Default for PendingWakeV1 { } impl PendingWakeV1 { + fn has_pending_arrival(&self) -> bool { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .micros + != 0 + } + #[cfg(test)] fn note_foreign_wake_attempt_for_test(&self) { let gate = self @@ -691,6 +731,13 @@ impl Drop for PendingWakeClaimV1 { #[derive(Clone)] pub(crate) struct CodeIndexSchedulerRegistryV1 { pub(super) max_worktrees: usize, + /// Durable daemon-authority epoch shared by every progress producer in + /// this registry. This is never derived from wall-clock time. + pub(super) progress_daemon_incarnation: u64, + /// Next scheduler-owner token within `progress_daemon_incarnation`. + /// Cloned registries share this authority, so same-daemon retire/remounts + /// cannot reuse a progress ordering key. + pub(super) next_progress_producer_incarnation: Arc, pub(super) resident_memory: Arc, pub(super) byte_pool: Arc, pub(super) mounted: Arc>>, @@ -720,6 +767,22 @@ pub(crate) struct CodeIndexSchedulerRegistryV1 { } impl CodeIndexSchedulerRegistryV1 { + fn incomplete_text_slice_may_continue(pending_wake: &PendingWakeV1) -> bool { + !pending_wake.has_pending_arrival() + } + + fn mint_progress_producer_incarnation(&self) -> Result { + self.next_progress_producer_incarnation + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current.checked_add(1) + }) + .map_err(|_| { + CodeIndexSchedulerErrorV1::Identity( + "code-index progress producer incarnation authority is exhausted".to_owned(), + ) + }) + } + #[hotpath::measure] pub(in crate::daemon) fn register_activation( &self, @@ -1467,6 +1530,10 @@ impl CodeIndexSchedulerRegistryV1 { .write() .unwrap_or_else(std::sync::PoisonError::into_inner); *serving = None; + *worktree + .text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; worktree .serving_generation_epoch .fetch_add(1, Ordering::AcqRel); @@ -1720,12 +1787,14 @@ impl CodeIndexSchedulerRegistryV1 { async fn seat_retained_serving_generation( scheduler: &Arc>, serving_generation: &Arc>>, + text_generation: &Arc>>, serving_generation_epoch: &Arc, wake: &Arc, - retained: LatestCompleteCodeIndexV1, + mut retained: LatestCompleteCodeIndexV1, ) { let swap_scheduler = Arc::clone(scheduler); let swap_serving = Arc::clone(serving_generation); + let swap_text = Arc::clone(text_generation); let swap_serving_epoch = Arc::clone(serving_generation_epoch); let seated = tokio::task::spawn_blocking(move || { let scheduler = swap_scheduler @@ -1735,6 +1804,18 @@ impl CodeIndexSchedulerRegistryV1 { .active_publication_matches(&retained) .unwrap_or(false) { + let mut text = swap_text + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = text.as_ref() + && existing.metadata().manifest().generation_id + == retained.generation().manifest().generation_id + { + retained.text = existing.clone(); + } else { + *text = Some(retained.text_generation_handle()); + } + drop(text); let mut serving = swap_serving .write() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1794,6 +1875,17 @@ impl CodeIndexSchedulerRegistryV1 { cadence_outcome, overflow_reconciled, ); + // The cadence receipt is created only after the serving-generation + // swap, so this is the truthful end-to-end wake-to-queryable sample. + // An un-attributable follow-up pass remains absent rather than + // fabricating a zero-latency sample. + #[cfg(feature = "hotpath")] + if let Some(ttfq_micros) = receipt.event_to_ready_micros() { + hotpath::gauge!("code_index_reconcile_wake_to_queryable_micros") + .set(ttfq_micros as f64); + } else { + hotpath::gauge!("code_index_reconcile_wake_without_arrival_total").inc(1_u64); + } // A successful publication is the terminal outcome operators need to see // to know a rebuild window actually closed, so it is `info`, not `debug`: // the cadence receipt below is debug-level and was invisible in the @@ -1922,6 +2014,7 @@ impl CodeIndexSchedulerRegistryV1 { "code-index scheduler capacity is zero".to_owned(), )); } + let producer_incarnation = self.mint_progress_producer_incarnation()?; CodeIndexWorktreeSchedulerV1::open( project_id, project_root, @@ -1931,6 +2024,8 @@ impl CodeIndexSchedulerRegistryV1 { .map(|mut scheduler| { scheduler.bind_resident_memory(Arc::clone(&self.resident_memory)); scheduler + .bind_progress_incarnations(self.progress_daemon_incarnation, producer_incarnation); + scheduler }) } @@ -2161,6 +2256,8 @@ impl CodeIndexSchedulerRegistryV1 { let open_byte_pool = Arc::clone(&self.byte_pool); let open_semantic_schedule = semantic_schedule.clone(); let open_resident_memory = Arc::clone(&self.resident_memory); + let progress_daemon_incarnation = self.progress_daemon_incarnation; + let progress_producer_incarnation = self.mint_progress_producer_incarnation()?; let (opened, cold_mount_reservation) = tokio::task::spawn_blocking(move || { #[cfg(test)] Self::pause_cold_mount_open_for_test(&open_project_root); @@ -2175,6 +2272,10 @@ impl CodeIndexSchedulerRegistryV1 { let mut opened = opened?; opened.replace_semantic_schedule_hook(open_semantic_schedule); opened.bind_resident_memory(open_resident_memory); + opened.bind_progress_incarnations( + progress_daemon_incarnation, + progress_producer_incarnation, + ); Ok::<_, CodeIndexSchedulerErrorV1>((opened, cold_mount_reservation)) }) .await @@ -2185,11 +2286,14 @@ impl CodeIndexSchedulerRegistryV1 { let worktree_id = opened.identity().worktree_id().clone(); let reconcile_in_progress = opened.reconcile_in_progress(); let active_generation_encoded_bytes = opened.active_generation_encoded_bytes(); + let build_progress = opened.build_progress_slot(); // Cold mount publishes only the exact route. The worker may seat a // complete identity-valid generation as stale serving before refresh // claims freshness; missing Git authority still leaves this empty. let serving_generation: Arc>> = Arc::new(RwLock::new(None)); + let text_generation: Arc>> = + Arc::new(RwLock::new(None)); let serving_generation_epoch = Arc::new(AtomicU64::new(0)); let serving_generation_installation = Arc::new(Mutex::new(None)); let hints = Arc::clone(&opened.hints); @@ -2206,6 +2310,7 @@ impl CodeIndexSchedulerRegistryV1 { let worker_scheduler = Arc::clone(&scheduler); let worker_reconcile_in_progress = Arc::clone(&reconcile_in_progress); let worker_serving_generation = Arc::clone(&serving_generation); + let worker_text_generation = Arc::clone(&text_generation); let worker_serving_generation_epoch = Arc::clone(&serving_generation_epoch); let worker_wake = Arc::clone(&wake); let worker_pending_wake = Arc::clone(&pending_wake); @@ -2308,14 +2413,16 @@ impl CodeIndexSchedulerRegistryV1 { let scheduler = Arc::clone(&worker_scheduler); let serving_generation = Arc::clone(&worker_serving_generation); let serving_generation_epoch = Arc::clone(&worker_serving_generation_epoch); + let graph_activation_enabled = worker_graph_activation.policy().is_enabled(); // Cover wake claim through failed-arrival restoration so admission // never misreads in-flight owner work as plain unavailability. let _reconcile_pass = super::ReconcilePassGuard::enter(&worker_reconcile_in_progress); - let text_generation = serving_generation + let text_generation = worker_text_generation .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + let mut text_slice_incomplete = false; if let Some(latest) = text_generation && latest.text_serving_needs_work() { @@ -2328,9 +2435,23 @@ impl CodeIndexSchedulerRegistryV1 { Ok(Ok(true)) => {} Ok(Ok(false)) => { worker_wake.notify_one(); - continue; + text_slice_incomplete = true; } Ok(Err(error)) => { + if matches!( + &error, + tracedecay_query::retrieval::RetrievalPortError::Cancelled + ) { + let mut current = worker_text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if current + .as_ref() + .is_some_and(|current| current.same_text_owner(&failed_latest)) + { + *current = None; + } + } tracing::warn!( event = "code_index_text_projection_failed", error = %error, @@ -2347,6 +2468,45 @@ impl CodeIndexSchedulerRegistryV1 { } } } + if worker_shutting_down.load(Ordering::Acquire) { + return; + } + if text_slice_incomplete { + if !graph_activation_enabled { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.slice.continue_total").inc(1_u64); + continue; + } + if Self::incomplete_text_slice_may_continue(&worker_pending_wake) { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.slice.continue_total").inc(1_u64); + continue; + } + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.slice.yield_to_reconcile_total").inc(1_u64); + } + if worker_text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() + { + let text_scheduler = Arc::clone(&scheduler); + let retained_text = tokio::task::spawn_blocking(move || { + text_scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .servable_retained_text_generation() + }) + .await; + if let Ok(Some(retained_text)) = retained_text { + *worker_text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(retained_text); + worker_wake.notify_one(); + continue; + } + } // Admission is held: queue wait ends and service time begins. let started_micros = now_micros().0; let (arrival, trigger) = Self::take_pending_arrival( @@ -2358,10 +2518,11 @@ impl CodeIndexSchedulerRegistryV1 { // split must not hide a sealed generation for the duration // of reconcile. Stale is truthful; do not mark_reconciled. let mut seat_retry_pending = false; - if serving_generation - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none() + if graph_activation_enabled + && serving_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() { let remount_scheduler = Arc::clone(&scheduler); let remount = tokio::task::spawn_blocking(move || { @@ -2404,6 +2565,7 @@ impl CodeIndexSchedulerRegistryV1 { Self::seat_retained_serving_generation( &scheduler, &serving_generation, + &worker_text_generation, &serving_generation_epoch, &worker_wake, retained, @@ -2443,6 +2605,7 @@ impl CodeIndexSchedulerRegistryV1 { Self::seat_retained_serving_generation( &scheduler, &serving_generation, + &worker_text_generation, &serving_generation_epoch, &worker_wake, retained, @@ -2464,17 +2627,38 @@ impl CodeIndexSchedulerRegistryV1 { } continue; } + let retained_text_metadata = (!graph_activation_enabled).then(|| { + worker_text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|text| text.metadata().clone()) + }); let mut result = tokio::task::spawn_blocking(move || { let mut scheduler = scheduler .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut result = scheduler.activate_or_reconcile(); + let mut result = if graph_activation_enabled { + scheduler.activate_or_reconcile() + } else if let Some(metadata) = retained_text_metadata.flatten() { + match scheduler.reconcile_retained_text_generation(&metadata) { + Ok(Some(outcome)) => Ok(outcome), + Ok(None) => scheduler.reconcile_now(), + Err(error) => Err(error), + } + } else { + scheduler.reconcile_now() + }; // A terminal outcome may publish a newer complete generation; // swap serving to that after graph activation below. - let mut latest = result - .as_ref() - .ok() - .and_then(|_| scheduler.latest_complete()); + let mut latest = graph_activation_enabled + .then(|| { + result + .as_ref() + .ok() + .and_then(|_| scheduler.latest_complete()) + }) + .flatten(); let replay_binding = latest.as_ref().map(|latest| { scheduler.code_graph_replay_binding( &latest.generation().manifest().generation_id, @@ -2491,6 +2675,39 @@ impl CodeIndexSchedulerRegistryV1 { (result, latest, replay_binding) }) .await; + if !graph_activation_enabled + && matches!( + &result, + Ok((Ok(CodeIndexReconcileOutcomeV1::Published(_)), None, None)) + ) + { + // Publication moved the durable pointer, so the prior text + // owner is no longer authoritative even while the new + // lightweight handle is opening. Withdraw it first: a + // failed or delayed reopen must report warming, never keep + // serving the superseded generation indefinitely. + *worker_text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + let text_scheduler = Arc::clone(&worker_scheduler); + let published_text = tokio::task::spawn_blocking(move || { + text_scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .servable_retained_text_generation() + }) + .await; + if let Ok(Some(published_text)) = published_text { + *worker_text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(published_text); + } + // A successful open advances B's bounded projection; an + // unavailable open retries the durable pointer without + // resurrecting A. + worker_wake.notify_one(); + } let replace_serving_generation = match &result { Ok((Ok(CodeIndexReconcileOutcomeV1::Noop(_)), Some(latest), Some(_))) => { let serving = worker_serving_generation @@ -2553,6 +2770,7 @@ impl CodeIndexSchedulerRegistryV1 { let scheduler = Arc::clone(&worker_scheduler); let serving_generation = Arc::clone(&worker_serving_generation); let serving_generation_epoch = Arc::clone(&worker_serving_generation_epoch); + let text_generation = Arc::clone(&worker_text_generation); let text_latest = latest.clone(); let latest = latest.clone(); let serving_swap = tokio::task::spawn_blocking(move || { @@ -2571,6 +2789,10 @@ impl CodeIndexSchedulerRegistryV1 { .unwrap_or_else(std::sync::PoisonError::into_inner); *serving = Some(latest.clone()); serving_generation_epoch.fetch_add(1, Ordering::AcqRel); + *text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(latest.text_generation_handle()); } // Semantic admission is independently retryable. A // prior attempt may have lost bounded queue capacity, @@ -2628,9 +2850,11 @@ impl CodeIndexSchedulerRegistryV1 { tracedecay_domain::QueueDepthBucketV1::OneToEight } }; - observability - .record_reconcile_outcome(outcome, service_micros, queue_depth_bucket) - .await; + observability.record_reconcile_outcome( + outcome, + service_micros, + queue_depth_bucket, + ); } } else { // Surface bounded non-terminal failure without new project-path data. @@ -2676,6 +2900,8 @@ impl CodeIndexSchedulerRegistryV1 { semantic_vector_graph_provider: None, scheduler, serving_generation, + text_generation, + build_progress, serving_generation_epoch, serving_generation_installation, graph_activation, @@ -3245,16 +3471,25 @@ impl CodeIndexSchedulerRegistryV1 { // so one warmup/dashboard call during a rebuild parked a runtime worker // for the reconcile's whole duration AND serialized every code-index // query behind it: a silent, daemon-wide code-index outage. - let serving = { + let (serving, text) = { let mounted = self.mounted.lock().await; let worktree = mounted.get(&project_root)?; - Arc::clone(&worktree.serving_generation) + ( + Arc::clone(&worktree.serving_generation), + Arc::clone(&worktree.text_generation), + ) }; let latest = serving .read() .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone()?; - Some(latest.generation.manifest().generation_id.clone()) + .clone(); + if let Some(latest) = latest { + return Some(latest.generation.manifest().generation_id.clone()); + } + text.read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|latest| latest.metadata().manifest().generation_id.clone()) } /// Exact bounded dashboard projection for one mounted worktree. @@ -3269,16 +3504,35 @@ impl CodeIndexSchedulerRegistryV1 { project_root: &Path, ) -> Option { let canonical_root = project_root.canonicalize().ok()?; - let (scheduler, reconcile_in_progress, serving_generation) = { + let (scheduler, reconcile_in_progress, serving_generation, text_generation, build_progress) = { let mounted = self.mounted.lock().await; let worktree = mounted.get(&canonical_root)?; ( Arc::clone(&worktree.scheduler), Arc::clone(&worktree.reconcile_in_progress), Arc::clone(&worktree.serving_generation), + Arc::clone(&worktree.text_generation), + Arc::clone(&worktree.build_progress), ) }; tokio::task::spawn_blocking(move || { + let progress = hotpath::measure_block!("dashboard.code_index.progress", { + let progress = build_progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .snapshot() + .map(|snapshot| snapshot.as_ref().clone()); + #[cfg(feature = "hotpath")] + if let Some(progress) = progress.as_ref() { + let age_micros = now_micros() + .0 + .saturating_sub(progress.last_progress_micros) + .max(0); + hotpath::gauge!("dashboard.code_index.progress_age_micros") + .set(u64::try_from(age_micros).unwrap_or(u64::MAX)); + } + progress + }); let refreshing = reconcile_in_progress.load(Ordering::Acquire) != 0; let scheduler = match scheduler.try_lock() { Ok(scheduler) => scheduler, @@ -3288,11 +3542,23 @@ impl CodeIndexSchedulerRegistryV1 { .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + let text = text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let text_ready = text + .as_ref() + .is_some_and(LatestCodeTextGenerationV1::text_serving_is_ready); + let identity = if latest.is_some() { + dashboard_freshness_identity(latest.as_ref()) + } else { + dashboard_text_freshness_identity(text.as_ref()) + }; return crate::dashboard::code_index_freshness_api::CodeIndexWorktreeFreshnessV1 { worktree_root: canonical_root.display().to_string(), last_reconcile_micros: None, staleness_state: Some( - if latest.is_some() { + if latest.is_some() || text_ready { "refreshing" } else { "indexing" @@ -3301,7 +3567,8 @@ impl CodeIndexSchedulerRegistryV1 { ), hook_hint_count: None, coverage: "partial_refresh_in_progress".to_owned(), - ..dashboard_freshness_identity(latest.as_ref()) + progress, + ..identity }; } }; @@ -3311,24 +3578,36 @@ impl CodeIndexSchedulerRegistryV1 { .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + let text = text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let text_ready = text + .as_ref() + .is_some_and(LatestCodeTextGenerationV1::text_serving_is_ready); let hook_hint_count = scheduler.pending_hint_count(); let staleness_state = if refreshing { - if latest.is_some() { + if latest.is_some() || text_ready { "refreshing" } else { "indexing" } } else if stale || hook_hint_count != Some(0) { - if latest.is_some() { + if latest.is_some() || text_ready { "stale" } else { "indexing" } - } else if latest.is_some() { + } else if latest.is_some() || text_ready { "fresh" } else { "indexing" }; + let identity = if latest.is_some() { + dashboard_freshness_identity(latest.as_ref()) + } else { + dashboard_text_freshness_identity(text.as_ref()) + }; crate::dashboard::code_index_freshness_api::CodeIndexWorktreeFreshnessV1 { worktree_root: canonical_root.display().to_string(), last_reconcile_micros: scheduler.last_reconciled_at_micros(), @@ -3342,7 +3621,8 @@ impl CodeIndexSchedulerRegistryV1 { "partial_hook_hint_overflow" } .to_owned(), - ..dashboard_freshness_identity(latest.as_ref()) + progress, + ..identity } }) .await @@ -3359,12 +3639,13 @@ impl CodeIndexSchedulerRegistryV1 { let project_root = project_root.canonicalize().ok()?; // Clone the per-worktree handle under a short map lock, then drop the // registry guard before checking the mounted route. - let (scheduler, serving_generation, hints, wake, pending_wake) = { + let (scheduler, serving_generation, text_generation, hints, wake, pending_wake) = { let mounted = self.mounted.lock().await; let worktree = mounted.get(&project_root)?; ( Arc::clone(&worktree.scheduler), Arc::clone(&worktree.serving_generation), + Arc::clone(&worktree.text_generation), Arc::clone(&worktree.hints), Arc::clone(&worktree.wake), Arc::clone(&worktree.pending_wake), @@ -3425,6 +3706,18 @@ impl CodeIndexSchedulerRegistryV1 { } return Some(latest); } + // A graph-off mount can already own authenticated text serving + // while its graph-bearing generation deliberately remains + // unseated. That owner is a real remedy for lexical/exact reads; + // do not misclassify it as a cold open and inject an overflow that + // would supersede its bounded projection. + if text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + { + return None; + } // Cold open has no servable generation. Verification and any // rebuild stay with the retained owner; reads only request the // wake and return typed unavailable/unverified. @@ -3600,6 +3893,19 @@ impl CodeIndexSchedulerRegistryV1 { Arc::clone(&worktree.serving_generation), ) }; + // The census asks only whether a fully decoded generation is already + // seated. A graph-off mount deliberately leaves this slot empty while + // its authenticated text owner is warming. Return that known answer + // before entering the exact freshness probe: probing an unseated slot + // cannot produce a decoded owner, and on an initial lightweight mount + // it would turn `freshness_unknown` into a fabricated overflow wake. + if serving_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() + { + return None; + } let mut scheduler = match scheduler.try_lock() { Ok(scheduler) => scheduler, Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(), @@ -3658,13 +3964,28 @@ impl CodeIndexSchedulerRegistryV1 { // MCP search resolves its generation before it asks for query authority, // so this is the first authenticated demand boundary on that path. self.activate_for_scope(scope); - let root = { + let (root, graph_activation_enabled, text_generation) = { let mounted = self.mounted.try_lock().ok()?; - unique_mounted_for_scope(&mounted, scope) - .unique()? - .0 - .clone() + let (root, worktree) = unique_mounted_for_scope(&mounted, scope).unique()?; + ( + root.clone(), + worktree.graph_activation.policy().is_enabled(), + Arc::clone(&worktree.text_generation), + ) }; + // Graph-off mounts authenticate the sealed text source before its + // bounded projection becomes ready. During that window the text owner + // is the canonical warming authority; falling through to AwaitDecode + // would reconstruct the graph-bearing generation solely to report the + // same typed unavailability, defeating the lightweight cutover. + if !graph_activation_enabled + && text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + { + return None; + } let latest = self.latest_complete_ready_with(&root, admission).await?; // Checkout-identity gate: the ready ladder verified currency against // the live worktree, so a scope whose branch label was resolved on @@ -3682,6 +4003,106 @@ impl CodeIndexSchedulerRegistryV1 { /// never blocks on reconcile, gix status, or the scheduler mutex. A caller /// that takes this arm is serving an older complete generation and must /// mark its lanes stale; it must never present the result as current. + pub(in crate::daemon) async fn latest_text_serving_for_scope( + &self, + scope: &tracedecay_application::ResolvedScope, + ) -> Option { + let text_generation = { + let mounted = self.mounted.lock().await; + Arc::clone( + &unique_mounted_for_scope(&mounted, scope) + .unique()? + .1 + .text_generation, + ) + }; + let latest = text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone()?; + (text_matches_scope_identity(&latest, scope) && latest.text_serving_is_ready()) + .then_some(latest) + } + + /// Resolve graph-independent exact/lexical serving through the same cheap + /// freshness ladder as complete-generation queries. The immutable text + /// owner remains servable while a real edit is reconciled in the retained + /// background worker; a quiet repository only refreshes its stat witness + /// clock and posts no wake. + pub(in crate::daemon) async fn latest_text_fresh_for_scope( + &self, + scope: &tracedecay_application::ResolvedScope, + ) -> Option { + self.latest_text_serving_freshness_for_scope(scope) + .await + .map(|(latest, _)| latest) + } + + /// Resolve the graph-independent text owner together with the freshness + /// decision made by the same scheduler observation. A ready text artifact + /// is not inherently stale merely because native graph activation is off. + pub(in crate::daemon) async fn latest_text_serving_freshness_for_scope( + &self, + scope: &tracedecay_application::ResolvedScope, + ) -> Option<(LatestCodeTextGenerationV1, bool)> { + let (root, scheduler, text_generation, wake, pending_wake, reconcile_in_progress) = { + let mounted = self.mounted.lock().await; + let (root, worktree) = unique_mounted_for_scope(&mounted, scope).unique()?; + ( + root.clone(), + Arc::clone(&worktree.scheduler), + Arc::clone(&worktree.text_generation), + Arc::clone(&worktree.wake), + Arc::clone(&worktree.pending_wake), + Arc::clone(&worktree.reconcile_in_progress), + ) + }; + let scope = scope.clone(); + tokio::task::spawn_blocking(move || { + if gix::open(&root).is_err() { + return None; + } + let latest = text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .filter(|latest| { + latest.text_serving_is_ready() && text_matches_scope_identity(latest, &scope) + })?; + if pending_wake.has_pending_arrival() + || reconcile_in_progress.load(Ordering::Acquire) != 0 + { + // The existing worker owns the freshness remedy. Re-requesting + // here would enqueue a redundant pass behind it. + return Some((latest, false)); + } + let mut scheduler = match scheduler.try_lock() { + Ok(scheduler) => scheduler, + Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => { + Self::note_wake( + &pending_wake, + &wake, + CodeIndexCadenceTriggerV1::BusyFollowUp, + ); + return Some((latest, false)); + } + }; + let current = !scheduler.request_fresh_for_query_background(); + if !current { + Self::note_wake( + &pending_wake, + &wake, + CodeIndexCadenceTriggerV1::QueryAdmission, + ); + } + Some((latest, current)) + }) + .await + .ok() + .flatten() + } + pub(in crate::daemon) async fn latest_complete_serving_for_scope( &self, scope: &tracedecay_application::ResolvedScope, @@ -3758,7 +4179,7 @@ impl CodeIndexSchedulerRegistryV1 { } else { None }; - let (scheduler, serving_generation, hints, wake, pending_wake) = { + let (scheduler, serving_generation, text_generation, hints, wake, pending_wake) = { let Ok(mounted) = self.mounted.try_lock() else { return false; }; @@ -3768,6 +4189,7 @@ impl CodeIndexSchedulerRegistryV1 { ( Arc::clone(&worktree.scheduler), Arc::clone(&worktree.serving_generation), + Arc::clone(&worktree.text_generation), Arc::clone(&worktree.hints), Arc::clone(&worktree.wake), Arc::clone(&worktree.pending_wake), @@ -3814,7 +4236,11 @@ impl CodeIndexSchedulerRegistryV1 { let nothing_servable = serving_generation .read() .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none(); + .is_none() + && text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none(); // Nothing is servable at all, so the ladder's suppression cannot // apply: a reconcile is the only thing that can ever make this scope // answerable, and no other caller on this path will ask for it. @@ -4180,3 +4606,37 @@ fn feedback_document_logical_path( .map(|path| path.replace('\\', "/")) .ok_or_else(|| LspRuntimeFailure::new("feedback-document-path-unavailable")) } + +#[cfg(test)] +mod text_slice_fairness_tests { + use super::{CodeIndexCadenceTriggerV1, CodeIndexSchedulerRegistryV1, PendingWakeV1}; + + #[test] + fn pending_reconcile_is_serviced_between_bounded_text_slices() { + let pending = PendingWakeV1::default(); + let wake = tokio::sync::Notify::new(); + assert!( + CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), + "a text-only self-wake may advance the next bounded slice" + ); + + CodeIndexSchedulerRegistryV1::note_wake( + &pending, + &wake, + CodeIndexCadenceTriggerV1::HookHint, + ); + assert!( + !CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), + "a pending source reconcile must win before another text slice" + ); + + let _ = CodeIndexSchedulerRegistryV1::take_pending_arrival( + &pending, + CodeIndexCadenceTriggerV1::Mount, + ); + assert!( + CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), + "text continuation resumes only after reconcile claims the pending arrival" + ); + } +} diff --git a/src/daemon/code_index_scheduler/registry/ignored_dependencies.rs b/src/daemon/code_index_scheduler/registry/ignored_dependencies.rs index a440ab06be..e8d4510764 100644 --- a/src/daemon/code_index_scheduler/registry/ignored_dependencies.rs +++ b/src/daemon/code_index_scheduler/registry/ignored_dependencies.rs @@ -120,9 +120,6 @@ fn clone_scheduler_error(error: &CodeIndexSchedulerErrorV1) -> CodeIndexSchedule CodeIndexSchedulerErrorV1::SnapshotMemoryCapacityUnavailable => { CodeIndexSchedulerErrorV1::SnapshotMemoryCapacityUnavailable } - CodeIndexSchedulerErrorV1::SnapshotMemoryAdjustment(error) => { - CodeIndexSchedulerErrorV1::SnapshotMemoryAdjustment(*error) - } CodeIndexSchedulerErrorV1::WorkerPlan(error) => { CodeIndexSchedulerErrorV1::WorkerPlan(error.clone()) } diff --git a/src/daemon/code_index_scheduler/registry/resident_memory.rs b/src/daemon/code_index_scheduler/registry/resident_memory.rs index dae5d4377c..5b730fe36c 100644 --- a/src/daemon/code_index_scheduler/registry/resident_memory.rs +++ b/src/daemon/code_index_scheduler/registry/resident_memory.rs @@ -11,22 +11,41 @@ use super::CodeIndexSchedulerRegistryV1; impl CodeIndexSchedulerRegistryV1 { #[cfg(test)] pub fn new(max_worktrees: usize) -> Self { - Self::with_resident_memory( + Self::with_resident_memory_and_progress_producer_incarnation( max_worktrees, Arc::new(ProcessResidentMemoryV1::new( DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, )), + 1, ) } + #[cfg(any(test, feature = "test-transport"))] pub fn with_resident_memory( max_worktrees: usize, resident_memory: Arc, + ) -> Self { + Self::with_resident_memory_and_progress_producer_incarnation( + max_worktrees, + resident_memory, + 1, + ) + } + + /// Build a registry under one durable daemon epoch. The constructor name + /// is retained for its invocation-state caller; individual producer + /// incarnations are minted below this daemon authority for each scheduler. + pub fn with_resident_memory_and_progress_producer_incarnation( + max_worktrees: usize, + resident_memory: Arc, + progress_daemon_incarnation: u64, ) -> Self { let (generation_publications, _) = tokio::sync::broadcast::channel(super::GENERATION_PUBLICATION_CHANNEL_CAPACITY); Self { max_worktrees, + progress_daemon_incarnation: progress_daemon_incarnation.max(1), + next_progress_producer_incarnation: Arc::new(std::sync::atomic::AtomicU64::new(1)), resident_memory, byte_pool: Arc::new(super::SharedCodeIndexBytePoolV1::default()), mounted: Arc::new(tokio::sync::Mutex::new(std::collections::BTreeMap::new())), diff --git a/src/daemon/code_index_scheduler/registry/scope_identity.rs b/src/daemon/code_index_scheduler/registry/scope_identity.rs index 1a259b6d0a..c13ce8d96d 100644 --- a/src/daemon/code_index_scheduler/registry/scope_identity.rs +++ b/src/daemon/code_index_scheduler/registry/scope_identity.rs @@ -2,7 +2,18 @@ use tracedecay_application::ResolvedScope; -use super::super::LatestCompleteCodeIndexV1; +use super::super::{LatestCodeTextGenerationV1, LatestCompleteCodeIndexV1}; + +pub(in crate::daemon::code_index_scheduler) fn text_matches_scope_identity( + latest: &LatestCodeTextGenerationV1, + scope: &ResolvedScope, +) -> bool { + let metadata = latest.metadata(); + scope.validate().is_ok() + && metadata.manifest().project_id == scope.project_id + && metadata.snapshot().repository == scope.repository_id + && metadata.snapshot().worktree.as_ref() == Some(&scope.worktree_id) +} /// The serving scope gate: project, repository, and worktree must equal the /// admitted scope's checkout identity. It admits only canonical scope digests. diff --git a/src/daemon/code_index_scheduler/semantic_query_runtime.rs b/src/daemon/code_index_scheduler/semantic_query_runtime.rs index 953cf28e22..35eeddc3f2 100644 --- a/src/daemon/code_index_scheduler/semantic_query_runtime.rs +++ b/src/daemon/code_index_scheduler/semantic_query_runtime.rs @@ -358,6 +358,19 @@ impl CodeIndexSchedulerRegistryV1 { let query = self .execute_controlled_query(scope, input, control.clone()) .await?; + if self + .semantic_query_authority_for_scope(scope) + .await + .is_none() + { + let semantic = semantic_abstention( + mode, + SemanticAbstentionV1::CalibrationUnavailable, + Arc::clone(&query.authorized.fallback), + ) + .map_err(|error| bind_semantic_execution_error(&query.generation, error))?; + return Ok(ExecutedQuerySemanticSearchV1 { query, semantic }); + } let latest = match self.generation_for(scope, &query.generation).await { Ok(Some(latest)) => latest, Ok(None) => { diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 81b378447a..0a4e6506b4 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -1,9 +1,10 @@ use std::collections::BTreeSet; use std::fmt::Write as _; +use std::num::NonZeroU64; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Arc, OnceLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; use sha2::{Digest, Sha256}; use tempfile::TempDir; @@ -53,9 +54,9 @@ use super::registry::{ ServingGenerationRollbackOutcomeV1, }; use super::{ - CodeIndexCadenceOutcomeV1, CodeIndexCadenceTriggerV1, CodeIndexReconcileOutcomeV1, - CodeIndexSchedulerRegistryV1, CodeIndexWorktreeSchedulerV1, GenerationDecodeAdmissionV1, - SharedCodeIndexBytePoolV1, + CodeIndexBuildProgressStateV1, CodeIndexCadenceOutcomeV1, CodeIndexCadenceTriggerV1, + CodeIndexCommittedProgressSampleV1, CodeIndexReconcileOutcomeV1, CodeIndexSchedulerRegistryV1, + CodeIndexWorktreeSchedulerV1, GenerationDecodeAdmissionV1, SharedCodeIndexBytePoolV1, }; use crate::code_index::production::{CodeIndexAtomicPublicationPort, CodeIndexExecutionControlV1}; use crate::semantic_code::rerank_adapter::GenerationBoundCodeRerankViewsV1; @@ -69,6 +70,9 @@ use tracedecay_query::retrieval::rerank::{ BoundedRerankRuntimeV1, DeterministicLocalRerankExecutorV1, LocalRerankFailureV1, LocalRerankInputV1, LocalRerankPermitV1, RerankExecutionControlV1, }; +use tracedecay_query::retrieval::semantic::{ + SemanticAbstentionV1, SemanticExecutionControl, SemanticQueryModeV1, +}; use tracedecay_runtime_core::resident_memory::{ DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, ProcessResidentMemoryV1, }; @@ -201,6 +205,48 @@ fn scheduler( .expect("open worktree scheduler") } +fn build_progress_snapshot( + scheduler: &CodeIndexWorktreeSchedulerV1, +) -> Arc { + scheduler + .build_progress_slot() + .read() + .expect("build progress slot") + .snapshot() + .expect("published build progress") +} + +fn progress_snapshot_for_generation( + generation_id: &CodeGenerationId, + committed_pages: u64, +) -> crate::dashboard::code_index_freshness_api::CodeIndexBuildProgressV1 { + crate::dashboard::code_index_freshness_api::CodeIndexBuildProgressV1 { + generation_id: generation_id.as_str().to_owned(), + daemon_incarnation: 1, + producer_incarnation: 1, + progress_epoch: 0, + sealed_source_digest: format!("sha256:{}", "a".repeat(64)), + phase: crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::BulkCommit, + committed_pages, + committed_chunks: committed_pages, + committed_imports: 0, + committed_payload_bytes: committed_pages, + completed_files: committed_pages, + total_files: 100, + completed_lexical_bytes: committed_pages, + total_lexical_bytes: 100, + current_batch_pages: 0, + current_batch_payload_bytes: 0, + elapsed_micros: 1, + last_commit_latency_micros: None, + files_per_second: None, + lexical_bytes_per_second: None, + estimated_remaining_seconds: None, + last_progress_micros: 1, + blocked_reason: None, + } +} + fn published(outcome: CodeIndexReconcileOutcomeV1) -> super::CodeIndexPublishEvidenceV1 { match outcome { CodeIndexReconcileOutcomeV1::Published(evidence) => evidence, @@ -1013,6 +1059,18 @@ impl RerankExecutionControlV1 for ReadyRerankControlV1 { } } +struct ReadySemanticControlV1; + +impl SemanticExecutionControl for ReadySemanticControlV1 { + fn is_cancelled(&self) -> bool { + false + } + + fn elapsed_micros(&self) -> u64 { + 0 + } +} + fn application_context( operation: &tracedecay_application::ApplicationOperation, repository: RepositoryId, @@ -1518,10 +1576,25 @@ fn saved_edit_incremental_publish() { !latest.graph_edges().is_empty() || !latest.graph_abstentions().is_empty(), "graph lane must remain explicitly queryable" ); + let mut text_passes = 0_usize; + while !latest + .advance_text_serving(64) + .expect("advance bounded production text serving") + { + text_passes += 1; + assert!( + text_passes < 10_000, + "incremental generation text serving never became ready" + ); + } let owners = latest .production_query_owners() .expect("production exact/lexical/graph owners connect"); - let _ = owners.is_artifact_backed(); + assert!( + owners.is_artifact_backed(), + "incremental publish must serve real durable exact/lexical owners" + ); + latest.warm_serving_caches(); let _ = latest .production_graph_serving() .expect("graph owner is activated"); @@ -2263,7 +2336,7 @@ fn production_text_serving_builds_publishes_and_reopens_the_artifact_head() { "pub fn caller() { callee(); }\npub fn callee() {}\n", )]); let store = TempDir::new().expect("store root"); - { + let completed_before_restart = { let mut scheduler = scheduler( &fixture, store.path().to_path_buf(), @@ -2289,7 +2362,13 @@ fn production_text_serving_builds_publishes_and_reopens_the_artifact_head() { .is_artifact_backed(), "a durable store must serve text queries from the published artifact" ); - } + let progress = build_progress_snapshot(&scheduler); + assert_eq!( + progress.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + ); + progress + }; // The durable pointer must name the published content-addressed artifact. let pointer: serde_json::Value = serde_json::from_slice( @@ -2347,6 +2426,62 @@ fn production_text_serving_builds_publishes_and_reopens_the_artifact_head() { owners.is_artifact_backed(), "the reopened owners must serve from the durable artifact" ); + let completed_after_restart = build_progress_snapshot(&scheduler); + assert_eq!( + completed_after_restart.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + ); + assert_eq!( + completed_after_restart.generation_id, + completed_before_restart.generation_id + ); + assert_eq!( + completed_after_restart.sealed_source_digest, + completed_before_restart.sealed_source_digest + ); + assert_eq!( + completed_after_restart.committed_pages, + completed_before_restart.committed_pages + ); + assert_eq!( + completed_after_restart.committed_chunks, + completed_before_restart.committed_chunks + ); + assert_eq!( + completed_after_restart.committed_imports, + completed_before_restart.committed_imports + ); + assert_eq!( + completed_after_restart.committed_payload_bytes, + completed_before_restart.committed_payload_bytes + ); + assert_eq!( + completed_after_restart.completed_files, + completed_before_restart.completed_files + ); + assert_eq!( + completed_after_restart.total_files, + completed_before_restart.total_files + ); + assert_eq!( + completed_after_restart.completed_lexical_bytes, + completed_before_restart.completed_lexical_bytes + ); + assert_eq!( + completed_after_restart.total_lexical_bytes, + completed_before_restart.total_lexical_bytes + ); + assert_eq!( + completed_after_restart.completed_files, + completed_after_restart.total_files + ); + assert_eq!( + completed_after_restart.completed_lexical_bytes, + completed_after_restart.total_lexical_bytes + ); + assert_eq!(completed_after_restart.files_per_second, None); + assert_eq!(completed_after_restart.lexical_bytes_per_second, None); + assert_eq!(completed_after_restart.estimated_remaining_seconds, None); let generation = latest.generation().manifest().generation_id.clone(); let base = RetrievalRequest { @@ -2436,6 +2571,90 @@ fn production_text_serving_builds_publishes_and_reopens_the_artifact_head() { ); } +#[test] +fn retained_text_generation_reaches_query_owners_without_full_sealed_decode() { + let fixture = GitFixture::new(&[( + "src/lib.rs", + "pub fn caller() { callee(); }\npub fn callee() {}\n", + )]); + let store = TempDir::new().expect("store root"); + { + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("seed generation")); + } + + let mut reopened = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + assert_eq!(reopened.sealed_decode_count(), 0); + let text = reopened + .servable_retained_text_generation() + .expect("active durable text generation"); + let binding = text + .publication_binding + .clone() + .expect("retained generation binding"); + let mut retained_history_update = reopened + .publication + .read_publication_pointer() + .expect("read active pointer") + .expect("active pointer"); + retained_history_update.generation_index.clear(); + retained_history_update.generation_index_truncated = true; + retained_history_update.generation_index_digest = None; + assert!( + binding.matches(Some(&retained_history_update)), + "retention-history changes must not supersede the same active seal" + ); + let scan = build_progress_snapshot(&reopened); + assert_eq!( + scan.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::SourceScan + ); + assert!(scan.total_lexical_bytes > 0); + assert_eq!(scan.completed_lexical_bytes, scan.total_lexical_bytes); + assert_eq!( + reopened.sealed_decode_count(), + 0, + "binding authenticated text metadata must not decode the full generation" + ); + while !text + .advance_text_serving(64) + .expect("advance retained text generation") + {} + assert!(text.query_owners_are_warm()); + assert_eq!( + reopened.sealed_decode_count(), + 0, + "exact and lexical owners must not require the full generation" + ); + assert!( + text.advance_text_serving(1) + .expect("attached text descriptor preserves the active seal"), + "a text-head pointer update must not retire its own ready owner" + ); + + fixture.edit( + "src/lib.rs", + "pub fn caller() { replacement(); }\npub fn replacement() {}\n", + ); + published( + reopened + .reconcile_now() + .expect("publish superseding generation"), + ); + assert!(matches!( + text.advance_text_serving(1), + Err(tracedecay_query::retrieval::RetrievalPortError::Cancelled) + )); +} + #[test] fn text_artifact_publication_serializes_pointer_attachment_with_retention() { use std::sync::{Condvar, Mutex, mpsc}; @@ -2539,7 +2758,7 @@ fn text_artifact_publication_serializes_pointer_attachment_with_retention() { let publisher = thread::spawn(move || { artifact_store.publish( &staging, - &generation, + &generation.manifest().generation_id, &sealed_identity, publish_control.as_ref(), ) @@ -2665,7 +2884,12 @@ fn text_artifact_publish_rejects_a_permissive_artifacts_root() { assert!( matches!( - artifact_store.publish(&staging, generation, &sealed_identity, &NeverCancelled), + artifact_store.publish( + &staging, + &generation.manifest().generation_id, + &sealed_identity, + &NeverCancelled, + ), Err(tracedecay_query::retrieval::RetrievalPortError::Contract(_)) ), "publication must fail closed instead of accepting a permissive artifact namespace" @@ -2712,7 +2936,12 @@ fn text_artifact_publish_rejects_a_symlink_artifacts_root() { assert!( matches!( - artifact_store.publish(&staging, generation, &sealed_identity, &NeverCancelled), + artifact_store.publish( + &staging, + &generation.manifest().generation_id, + &sealed_identity, + &NeverCancelled, + ), Err(tracedecay_query::retrieval::RetrievalPortError::Contract(_)) ), "publication must not traverse a symlink artifact namespace" @@ -3462,20 +3691,113 @@ fn concurrent_background_wakes_share_one_generation_owned_text_builder() { } #[test] -fn scheduler_shutdown_cancels_and_resumes_the_durable_text_build() { - let sources = (0..12) - .map(|ordinal| { - ( - format!("src/file_{ordinal}.rs"), - format!("pub fn cancellation_symbol_{ordinal}() -> usize {{ {ordinal} }}\n"), - ) - }) - .collect::>(); - let borrowed = sources - .iter() - .map(|(path, source)| (path.as_str(), source.as_str())) - .collect::>(); - let fixture = GitFixture::new(&borrowed); +fn text_progress_rate_and_eta_require_two_monotonic_committed_samples() { + let mut state = CodeIndexBuildProgressStateV1::new(); + let first = Instant::now(); + state.observe_committed(CodeIndexCommittedProgressSampleV1 { + observed_at: first, + completed_files: 20, + completed_lexical_bytes: 4_000_000, + }); + assert_eq!(state.rates_and_eta(29_000_000), (None, None, None)); + + state.observe_committed(CodeIndexCommittedProgressSampleV1 { + observed_at: first + Duration::from_secs(2), + completed_files: 21, + completed_lexical_bytes: 14_000_000, + }); + let (files_per_second, lexical_bytes_per_second, eta_seconds) = state.rates_and_eta(29_000_000); + assert_eq!(files_per_second, Some(0.5)); + assert_eq!(lexical_bytes_per_second, Some(5_000_000.0)); + assert_eq!( + eta_seconds, + Some(3), + "ETA must use the multi-page lexical span, not uneven completed-file boundaries" + ); +} + +#[test] +fn progress_owner_epoch_rejects_original_a_after_a_b_a_replacement() { + let generation_a = CodeGenerationId::new("generation.progress-a").expect("generation A"); + let generation_b = CodeGenerationId::new("generation.progress-b").expect("generation B"); + let mut slot = super::CodeIndexBuildProgressSlotStateV1::default(); + + let original_a_owner = slot.replace_generation(generation_a.clone()); + assert!(slot.publish( + &generation_a, + original_a_owner, + progress_snapshot_for_generation(&generation_a, 1), + )); + + let generation_b_owner = slot.replace_generation(generation_b.clone()); + assert!(slot.publish( + &generation_b, + generation_b_owner, + progress_snapshot_for_generation(&generation_b, 2), + )); + + let replacement_a_owner = slot.replace_generation(generation_a.clone()); + assert_ne!(replacement_a_owner, original_a_owner); + assert!(slot.publish( + &generation_a, + replacement_a_owner, + progress_snapshot_for_generation(&generation_a, 3), + )); + let replacement_a = slot.snapshot().expect("replacement A snapshot"); + + assert!(!slot.publish( + &generation_a, + original_a_owner, + progress_snapshot_for_generation(&generation_a, 99), + )); + let after_stale_original = slot.snapshot().expect("current A snapshot"); + assert_eq!(after_stale_original.generation_id, generation_a.as_str()); + assert_eq!(after_stale_original.committed_pages, 3); + assert_eq!( + after_stale_original.progress_epoch, replacement_a.progress_epoch, + "matching generation identity must not let the original A owner replace the newer A epoch" + ); +} + +#[test] +fn dashboard_progress_advances_only_after_durable_batch_commit() { + struct CancelAfterPreparation { + progress: super::CodeIndexBuildProgressSlotV1, + observed_bulk_commit: std::sync::atomic::AtomicBool, + } + + impl CodeIndexExecutionControlV1 for CancelAfterPreparation { + fn is_cancelled(&self) -> bool { + let cancel = self + .progress + .read() + .expect("cancellation progress slot") + .snapshot() + .is_some_and(|snapshot| { + snapshot.phase + == crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::BulkCommit + }); + if cancel { + self.observed_bulk_commit + .store(true, std::sync::atomic::Ordering::Release); + } + cancel + } + + fn is_deadline_exceeded(&self) -> bool { + false + } + } + + let mut source = String::new(); + for ordinal in 0..600 { + writeln!( + source, + "pub fn cancellation_symbol_{ordinal}() -> usize {{ {ordinal} }}" + ) + .expect("write cancellation fixture"); + } + let fixture = GitFixture::new(&[("src/lib.rs", source.as_str())]); let store = TempDir::new().expect("store root"); let mut scheduler = scheduler( &fixture, @@ -3498,14 +3820,43 @@ fn scheduler_shutdown_cancels_and_resumes_the_durable_text_build() { .builder .progress() .expect("durable progress"); + let dashboard_before = build_progress_snapshot(&scheduler); + assert_eq!( + dashboard_before.committed_pages, + progress_before.next_page_ordinal + ); + assert_eq!(dashboard_before.committed_pages, 1); + assert!( + dashboard_before.completed_files < dashboard_before.total_files, + "the fixture must retain more authenticated source work after its first page" + ); - latest - .text_control_shutdown - .store(true, std::sync::atomic::Ordering::Release); + let control = CancelAfterPreparation { + progress: scheduler.build_progress_slot(), + observed_bulk_commit: std::sync::atomic::AtomicBool::new(false), + }; + let cancellation_started = Instant::now(); + let cancellation = { + let mut build = latest + .text_projection_build + .lock() + .expect("text build state for controlled cancellation"); + latest.advance_artifact_text_serving(&mut build, 1, &control) + }; assert_eq!( - latest.advance_text_serving(1), + cancellation, Err(tracedecay_query::retrieval::RetrievalPortError::Cancelled) ); + assert!( + cancellation_started.elapsed() < Duration::from_secs(1), + "a cancelled scheduler batch must yield within the focused latency bound" + ); + assert!( + control + .observed_bulk_commit + .load(std::sync::atomic::Ordering::Acquire), + "cancellation must occur after preparation publishes the bulk-commit boundary" + ); let progress_after = latest .text_projection_build .lock() @@ -3516,16 +3867,93 @@ fn scheduler_shutdown_cancels_and_resumes_the_durable_text_build() { .progress() .expect("durable progress after cancellation"); assert_eq!(progress_after, progress_before); + let dashboard_after = build_progress_snapshot(&scheduler); + assert_eq!( + dashboard_after.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::BulkCommit + ); + assert_eq!(dashboard_after.current_batch_pages, 1); + assert_eq!( + dashboard_after.committed_pages, dashboard_before.committed_pages, + "a prepared but cancelled batch must not publish staged pages as committed" + ); + assert_eq!( + dashboard_after.completed_lexical_bytes, dashboard_before.completed_lexical_bytes, + "a cancelled batch must retain the prior exact sealed-source boundary" + ); assert!(latest.text_serving_needs_work()); - latest - .text_control_shutdown - .store(false, std::sync::atomic::Ordering::Release); while !latest .advance_text_serving(8) .expect("resume durable text build") {} assert!(latest.query_owners_are_warm()); + let dashboard_ready = build_progress_snapshot(&scheduler); + assert_eq!( + dashboard_ready.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + ); + assert!(dashboard_ready.progress_epoch > dashboard_after.progress_epoch); +} + +#[test] +fn reopen_reconstructs_exact_committed_progress_without_fabricating_rate() { + let sources = (0..12) + .map(|ordinal| { + ( + format!("src/reopen_{ordinal}.rs"), + format!("pub fn reopen_symbol_{ordinal}() -> usize {{ {ordinal} }}\n"), + ) + }) + .collect::>(); + let borrowed = sources + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect::>(); + let fixture = GitFixture::new(&borrowed); + let store = TempDir::new().expect("store root"); + let bytes = Arc::new(SharedCodeIndexBytePoolV1::default()); + let committed = { + let mut scheduler = scheduler(&fixture, store.path().to_path_buf(), Arc::clone(&bytes)); + published(scheduler.reconcile_now().expect("publish generation")); + let latest = scheduler.latest_complete().expect("latest generation"); + assert!( + !latest + .advance_text_serving(1) + .expect("commit one bounded source batch") + ); + let progress = build_progress_snapshot(&scheduler); + assert!(progress.committed_pages > 0); + assert_eq!(progress.files_per_second, None); + assert_eq!(progress.lexical_bytes_per_second, None); + assert_eq!(progress.estimated_remaining_seconds, None); + progress + }; + + let reopened = scheduler(&fixture, store.path().to_path_buf(), bytes); + let latest = reopened.latest_complete().expect("reopened generation"); + assert!( + !latest + .advance_text_serving(0) + .expect("reconstruct durable cursor snapshot") + ); + let reconstructed = build_progress_snapshot(&reopened); + assert_eq!(reconstructed.generation_id, committed.generation_id); + assert_eq!(reconstructed.committed_pages, committed.committed_pages); + assert_eq!(reconstructed.committed_chunks, committed.committed_chunks); + assert_eq!(reconstructed.committed_imports, committed.committed_imports); + assert_eq!( + reconstructed.committed_payload_bytes, + committed.committed_payload_bytes + ); + assert_eq!(reconstructed.completed_files, committed.completed_files); + assert_eq!( + reconstructed.completed_lexical_bytes, + committed.completed_lexical_bytes + ); + assert_eq!(reconstructed.files_per_second, None); + assert_eq!(reconstructed.lexical_bytes_per_second, None); + assert_eq!(reconstructed.estimated_remaining_seconds, None); } #[test] @@ -3551,29 +3979,130 @@ fn ordinary_background_reconcile_does_not_supersede_in_flight_text_work() { } #[test] -fn generation_replacement_drops_incomplete_text_projection_state() { - let fixture = GitFixture::new(&[ - ("src/first.rs", "pub fn first() {}\n"), - ("src/second.rs", "pub fn second() {}\n"), - ("src/third.rs", "pub fn third() {}\n"), - ]); +fn same_daemon_scheduler_retire_remount_mints_new_progress_producer() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn producer_epoch() {}\n")]); + let store = TempDir::new().expect("store root"); + let registry = CodeIndexSchedulerRegistryV1::new(1); + let first = registry + .open_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + ) + .expect("first scheduler owner"); + let (first_daemon, first_producer) = first.progress_incarnations_for_test(); + drop(first); + let second = registry + .open_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + ) + .expect("replacement scheduler owner"); + let (second_daemon, second_producer) = second.progress_incarnations_for_test(); + + assert_eq!(second_daemon, first_daemon); + assert!( + second_producer > first_producer, + "same-daemon scheduler replacement must outrank delayed low-epoch progress" + ); +} + +#[test] +fn source_epoch_advance_does_not_discard_immutable_text_progress() { + let mut source = String::new(); + for ordinal in 0..600 { + writeln!( + source, + "pub fn immutable_symbol_{ordinal}() -> usize {{ {ordinal} }}" + ) + .expect("write immutable source fixture"); + } + let fixture = GitFixture::new(&[("src/lib.rs", source.as_str())]); let store = TempDir::new().expect("store root"); let mut scheduler = scheduler( &fixture, store.path().to_path_buf(), Arc::new(SharedCodeIndexBytePoolV1::default()), ); - published(scheduler.reconcile_now().expect("publish first generation")); - let original = scheduler.latest_complete().expect("original generation"); + published(scheduler.reconcile_now().expect("publish generation")); + let latest = scheduler.latest_complete().expect("latest generation"); + let admitted = latest.text_execution_control(); + + // Reproduce a hook arriving after a text pass captures its control but + // before the sealed source's first cancellation checkpoint. + scheduler.notify_path(fixture.path().join("src/lib.rs")); + let result = { + let mut build = latest + .text_projection_build + .lock() + .expect("generation text projection"); + latest.advance_artifact_text_serving(&mut build, 1, &admitted) + }; + assert!( - !original - .advance_text_serving(1) - .expect("start original text projection") + matches!(result, Ok(false | true)), + "a worktree freshness epoch must not cancel immutable generation work: {result:?}" ); - let original_state = Arc::downgrade(&original.text_projection_build); - let original_generation = original.generation().manifest().generation_id.clone(); + assert!( + latest + .text_projection_build + .lock() + .expect("retained text projection") + .is_some() + || latest.query_owners_are_warm(), + "the bounded pass must retain or complete its generation-owned progress" + ); +} - fixture.edit("src/first.rs", "pub fn first_replaced() {}\n"); +#[test] +fn shutdown_cancels_generation_owned_text_work() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn shutdown_text_work() {}\n")]); + let store = TempDir::new().expect("store root"); + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("publish generation")); + let latest = scheduler.latest_complete().expect("latest generation"); + scheduler + .shutting_down + .store(true, std::sync::atomic::Ordering::Release); + + assert_eq!( + latest.advance_text_serving(1), + Err(tracedecay_query::retrieval::RetrievalPortError::Cancelled), + "daemon shutdown must remain a cancellation fence for immutable text work" + ); +} + +#[test] +fn generation_replacement_drops_incomplete_text_projection_state() { + let fixture = GitFixture::new(&[ + ("src/first.rs", "pub fn first() {}\n"), + ("src/second.rs", "pub fn second() {}\n"), + ("src/third.rs", "pub fn third() {}\n"), + ]); + let store = TempDir::new().expect("store root"); + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("publish first generation")); + let original = scheduler.latest_complete().expect("original generation"); + let original_control = original.text_execution_control(); + assert!( + !original + .advance_text_serving(1) + .expect("start original text projection") + ); + let original_progress = build_progress_snapshot(&scheduler); + let original_state = Arc::downgrade(&original.text_projection_build); + let original_generation = original.generation().manifest().generation_id.clone(); + + fixture.edit("src/first.rs", "pub fn first_replaced() {}\n"); published( scheduler .reconcile_now() @@ -3584,10 +4113,57 @@ fn generation_replacement_drops_incomplete_text_projection_state() { replacement.generation().manifest().generation_id, original_generation ); + let superseded_result = { + let mut build = original + .text_projection_build + .lock() + .expect("superseded projection state"); + original.advance_artifact_text_serving(&mut build, 1, &original_control) + }; + assert_eq!( + superseded_result, + Err(tracedecay_query::retrieval::RetrievalPortError::Cancelled), + "a replacement serving generation must retire the prior text owner" + ); assert!(!Arc::ptr_eq( &original.text_projection_build, &replacement.text_projection_build )); + assert!( + scheduler + .build_progress_slot() + .read() + .expect("replacement progress slot") + .snapshot() + .is_none(), + "generation replacement clears the superseded snapshot before new work begins" + ); + assert!( + !replacement + .advance_text_serving(0) + .expect("publish replacement progress baseline") + ); + let replacement_progress = build_progress_snapshot(&scheduler); + assert_eq!( + replacement_progress.generation_id, + replacement.generation().manifest().generation_id.as_str() + ); + assert!(replacement_progress.progress_epoch > original_progress.progress_epoch); + original.publish_text_progress_phase( + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::BulkCommit, + 99, + 99, + ); + let progress_after_stale_publish = build_progress_snapshot(&scheduler); + assert_eq!( + progress_after_stale_publish.generation_id, + replacement_progress.generation_id + ); + assert_eq!( + progress_after_stale_publish.progress_epoch, replacement_progress.progress_epoch, + "the old generation/owner epoch must not replace a newer generation snapshot" + ); + assert_ne!(progress_after_stale_publish.current_batch_pages, 99); drop(original); assert!( original_state.upgrade().is_none(), @@ -3598,8 +4174,8 @@ fn generation_replacement_drops_incomplete_text_projection_state() { .text_projection_build .lock() .expect("replacement projection state") - .is_none(), - "a replacement generation starts with independent empty projection state" + .is_some(), + "publishing the replacement baseline initializes only its independent projection state" ); } @@ -4807,6 +5383,81 @@ async fn dashboard_freshness_projects_the_mounted_scheduler_generation() { assert_eq!(projected.coverage, "complete"); } +#[tokio::test] +async fn dashboard_progress_does_not_wait_for_the_scheduler_mutex() { + let sources = (0..12) + .map(|ordinal| { + ( + format!("src/dashboard_{ordinal}.rs"), + format!("pub fn dashboard_symbol_{ordinal}() -> usize {{ {ordinal} }}\n"), + ) + }) + .collect::>(); + let borrowed = sources + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect::>(); + let fixture = GitFixture::new(&borrowed); + let store = TempDir::new().expect("store root"); + let registry = CodeIndexSchedulerRegistryV1::new(1); + registry + .mount_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + ) + .await + .expect("mount daemon-owned scheduler"); + wait_for_initial_generation(®istry, fixture.path()).await; + let canonical_root = fixture + .path() + .canonicalize() + .expect("canonical fixture root"); + let (scheduler, progress_slot) = { + let mounted = registry.mounted.lock().await; + let worktree = mounted.get(&canonical_root).expect("mounted worktree"); + ( + Arc::clone(&worktree.scheduler), + Arc::clone(&worktree.build_progress), + ) + }; + let expected = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(progress) = progress_slot.read().expect("progress slot").snapshot() { + break progress; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("background text build publishes progress"); + + let (locked_tx, locked_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let scheduler_holder = tokio::task::spawn_blocking(move || { + let _scheduler_guard = scheduler.lock().expect("hold scheduler mutex"); + let _ = locked_tx.send(()); + let _ = release_rx.blocking_recv(); + }); + locked_rx.await.expect("scheduler mutex holder started"); + let projected = tokio::time::timeout( + Duration::from_secs(1), + registry.dashboard_freshness(fixture.path()), + ) + .await + .expect("dashboard projection must not wait for scheduler") + .expect("mounted freshness projection"); + let projected_progress = projected.progress.expect("projected progress snapshot"); + assert_eq!(projected_progress.generation_id, expected.generation_id); + assert!(projected_progress.progress_epoch >= expected.progress_epoch); + let _ = release_tx.send(()); + scheduler_holder + .await + .expect("scheduler mutex holder joined"); + registry.shutdown().await; +} + /// A dashboard status view reports the last execution-owned scheduler state; it /// must not run the freshness ladder, wake a worker, or publish an out-of-band /// source change merely because an operator opened the view. @@ -8789,11 +9440,11 @@ async fn witness_verified_mount_activates_without_rebuild() { registry.shutdown().await; } -/// A retained generation is not serving until its persistent graph replay -/// activates. A typed replay failure leaves the slot empty while the ordinary -/// refresh remains pending. +/// A retained text generation remains serving when persistent graph replay +/// fails. The full graph owner stays absent while ordinary refresh remains +/// pending, so exact/lexical availability never implies graph availability. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn failed_cold_mount_graph_replay_never_seats_retained_generation() { +async fn failed_cold_mount_graph_replay_preserves_retained_text_generation() { let fixture = GitFixture::new(ALPHA_LIB_V1); let store = TempDir::new().expect("store root"); let bytes = Arc::new(SharedCodeIndexBytePoolV1::default()); @@ -8801,18 +9452,21 @@ async fn failed_cold_mount_graph_replay_never_seats_retained_generation() { store.path(), &fixture.path().canonicalize().expect("canonical fixture"), ); - let scope = { + let (scope, seeded_generation_id) = { let mut scheduler = scheduler(&fixture, scoped_store, bytes); published(scheduler.reconcile_now().expect("seed generation")); let latest = scheduler.latest_complete().expect("seeded generation"); let snapshot = latest.generation.snapshot(); - ResolvedScope::new( - test_project_id(), - snapshot.repository.clone(), - snapshot.worktree.clone().expect("worktree id"), - snapshot.reference.clone(), + ( + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + snapshot.worktree.clone().expect("worktree id"), + snapshot.reference.clone(), + ) + .expect("resolved scope"), + latest.generation.manifest().generation_id.clone(), ) - .expect("resolved scope") }; let profile = TempDir::new().expect("profile root"); @@ -8877,31 +9531,22 @@ async fn failed_cold_mount_graph_replay_never_seats_retained_generation() { .scheduler, ) }; - let (held_tx, held_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); - let lock_thread = std::thread::spawn(move || { - let _guard = scheduler - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - held_tx.send(()).expect("signal scheduler held"); - let _ = release_rx.recv(); - }); - held_rx.recv().expect("scheduler lock acquired"); drop(admission); let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { - if registry.pending_wake_micros_for_scope(&scope).await == Some(0) { + if scheduler + .try_lock() + .is_ok_and(|scheduler| scheduler.sealed_decode_count() > 0) + { break; } assert!( std::time::Instant::now() <= deadline, - "worker did not dequeue the retained graph replay" + "worker did not decode the retained generation for graph replay" ); tokio::time::sleep(Duration::from_millis(10)).await; } - release_tx.send(()).expect("release scheduler"); - lock_thread.join().expect("join scheduler holder"); let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { @@ -8921,8 +9566,15 @@ async fn failed_cold_mount_graph_replay_never_seats_retained_generation() { assert_eq!( registry.latest_generation_id(fixture.path()).await, - None, - "a retained generation cannot serve after persistent graph replay fails" + Some(seeded_generation_id), + "persistent graph replay failure must not withhold retained text serving" + ); + assert!( + registry + .latest_complete_serving_for_scope(&scope) + .await + .is_none(), + "persistent graph replay failure must not expose a full graph owner" ); registry.shutdown().await; graph_runtime @@ -9232,71 +9884,56 @@ async fn resident_memory_graph_refusal_seats_text_serving_without_graph() { registry.shutdown().await; } -/// The canonical project setting must reach the scheduler's production policy -/// boundary. A configured refusal still verifies and seats the sealed text -/// generation, while the graph authority is never activated. +/// A graph-off cold mount must keep the authenticated lightweight text owner +/// authoritative when an overflow reconcile arrives between bounded text +/// slices. Rebinding the same sealed generation through the full-generation +/// cache clears the live progress slot, invalidates the surviving owner's +/// epoch, and decodes gigabytes that graph-off serving never needs. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pinned_configuration_refuses_native_graph_before_text_serving_swap() { - let fixture = GitFixture::new(ALPHA_LIB_V1); +async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() { + let sources = (0..512) + .map(|index| { + ( + format!("src/file_{index:04}.rs"), + format!("pub fn alpha_{index:04}() -> usize {{ {index} }}\n"), + ) + }) + .collect::>(); + let source_refs = sources + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect::>(); + let fixture = GitFixture::new(&source_refs); let store = TempDir::new().expect("store root"); let scoped_store = super::scoped_code_index_store_root( store.path(), &fixture.path().canonicalize().expect("canonical fixture"), ); - let scope = { + let (scope, privacy_domain) = { let mut scheduler = scheduler( &fixture, scoped_store, Arc::new(SharedCodeIndexBytePoolV1::default()), ); - published(scheduler.reconcile_now().expect("seed generation")); + published(scheduler.reconcile_now().expect("seed retained generation")); let latest = scheduler.latest_complete().expect("seeded generation"); let snapshot = latest.generation.snapshot(); - ResolvedScope::new( - test_project_id(), - snapshot.repository.clone(), - snapshot.worktree.clone().expect("worktree id"), - snapshot.reference.clone(), - ) - .expect("resolved scope") - }; - - let configuration_registry = - crate::config::registry::ConfigurationRegistry::core().expect("configuration registry"); - let setting = tracedecay_domain::configuration::SettingKey::new( - tracedecay_domain::configuration::INDEX_NATIVE_GRAPH_ACTIVATION_SETTING_KEY, - ) - .expect("native graph setting key"); - let layer = crate::config::resolver::ConfigurationLayerV1 { - layer: tracedecay_domain::configuration::ConfigurationLayerIdV1::Project { - project_id: test_project_id(), - }, - revision_id: tracedecay_domain::configuration::ConfigurationRevisionId::new( - "revision.native-graph-refusal.1", + ( + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + snapshot.worktree.clone().expect("worktree id"), + snapshot.reference.clone(), + ) + .expect("resolved scope"), + latest.generation.manifest().privacy_domain.clone(), ) - .expect("configuration revision"), - entries: std::collections::BTreeMap::from([( - setting, - tracedecay_domain::configuration::ConfigurationValueV1::Boolean(false), - )]), }; - let snapshot = - crate::config::resolver::resolve_configuration(&configuration_registry, &[layer]) - .expect("resolve configured native graph refusal") - .snapshot; - let config = tracedecay_usecases::config::PinnedRuntimeConfiguration::new( - tracedecay_usecases::config::RuntimeConfigurationTarget { - project_id: test_project_id(), - project_root: fixture.path().to_path_buf(), - }, - tracedecay_domain::configuration::ConfigurationRevisionId::new( - "revision.native-graph-refusal.1", - ) - .expect("pinned configuration revision"), - snapshot, + std::fs::remove_file( + super::scoped_code_index_store_root(store.path(), fixture.path()) + .join("freshness_witness.v1"), ) - .expect("materialize pinned runtime configuration"); - assert!(!config.config.native_graph_activation); + .expect("remove restore witness to reproduce a cold preserved-profile mount"); let registry = CodeIndexSchedulerRegistryV1::with_background_reconcile_permits(1, 1); registry @@ -9305,49 +9942,948 @@ async fn pinned_configuration_refuses_native_graph_before_text_serving_swap() { fixture.path(), store.path().to_path_buf(), None, - super::CodeGraphActivationPolicyV1::from_enabled(config.config.native_graph_activation), + super::CodeGraphActivationPolicyV1::RefusedByConfiguration, ) .await - .expect("mount scheduler under configured graph policy"); + .expect("mount graph-off scheduler"); + registry + .mount_query_authority(fixture.path(), &scope, query_authority(privacy_domain)) + .await + .expect("mount query authority"); + let scheduler = { + let mounted = registry.mounted.lock().await; + Arc::clone( + &mounted + .get(&fixture.path().canonicalize().expect("canonical root")) + .expect("mounted worktree") + .scheduler, + ) + }; - let deadline = std::time::Instant::now() + Duration::from_secs(5); - let latest = loop { - if let Some(latest) = registry.latest_complete_serving_for_scope(&scope).await { - break latest; + let progress_deadline = std::time::Instant::now() + Duration::from_secs(10); + let (owner_epoch_before_overflow, progress_before_overflow) = loop { + let observed = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (progress.owner_epoch, progress.snapshot()) + }; + if let (owner_epoch, Some(progress)) = observed + && progress.committed_pages > 0 + && progress.phase + != crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + { + break (owner_epoch, progress); } assert!( - std::time::Instant::now() <= deadline, - "configured graph refusal withheld the text-serving generation" + std::time::Instant::now() <= progress_deadline, + "text projection completed before exposing bounded live progress" ); - tokio::time::sleep(Duration::from_millis(10)).await; + tokio::task::yield_now().await; }; - registry - .mount_query_authority( - fixture.path(), - &scope, - query_authority(latest.generation.manifest().privacy_domain.clone()), - ) + let status_poll_admission = registry + .background_reconcile_admission() + .acquire_owned() .await - .expect("mount retained query authority"); - let text_deadline = std::time::Instant::now() + Duration::from_secs(5); - while !latest.query_owners_are_warm() { + .expect("pause text projection after a bounded committed slice"); + let (last_reconciled_before_status_polls, original_staleness_threshold) = { + let mut scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let original_staleness_threshold = scheduler.policy.staleness_threshold; + scheduler.policy.staleness_threshold = Duration::ZERO; + ( + scheduler.last_reconciled_at_micros(), + original_staleness_threshold, + ) + }; + for _ in 0..8 { assert!( - std::time::Instant::now() <= text_deadline, - "background text projection did not complete across bounded worker windows" + !registry.has_current_ready_decoded_for_root_scope(fixture.path(), &scope), + "graph-off status census has no fully decoded generation" ); - tokio::time::sleep(Duration::from_millis(10)).await; } - let executed = registry - .execute_query_search(&scope, core_search_request("alpha")) - .await - .expect("configured refusal preserves exact and lexical query serving"); - assert_eq!( - executed - .authorized - .fallback - .public_fallback_lane_coverage - .get(&RetrieverKind::ExactLiteral), - Some(&PublicRetrieverStatus::Complete) + { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!( + scheduler.pending_hint_count(), + Some(0), + "stat-equal status polling must not fabricate an overflow reconcile" + ); + assert_eq!( + scheduler.last_reconciled_at_micros(), + last_reconciled_before_status_polls, + "stat-equal status polling must not run a capture pass" + ); + assert_eq!( + scheduler.sealed_decode_count(), + 0, + "graph-off status polling must not decode the full generation" + ); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(progress.owner_epoch, owner_epoch_before_overflow); + let progress = progress + .snapshot() + .expect("status polling preserves live text progress"); + assert_eq!( + progress.daemon_incarnation, + progress_before_overflow.daemon_incarnation + ); + assert_eq!( + progress.producer_incarnation, + progress_before_overflow.producer_incarnation + ); + assert!(progress.progress_epoch >= progress_before_overflow.progress_epoch); + assert!(progress.committed_pages >= progress_before_overflow.committed_pages); + } + scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .policy + .staleness_threshold = original_staleness_threshold; + drop(status_poll_admission); + let last_reconciled_before_overflow = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .last_reconciled_at_micros(); + assert!( + registry.notify_hook_overflow(fixture.path()).await, + "mounted graph-off worktree accepts the overflow reconcile" + ); + + let query_deadline = std::time::Instant::now() + Duration::from_secs(10); + let executed = loop { + match registry + .execute_query_search(&scope, core_search_request("alpha_0000")) + .await + { + Ok(executed) => break executed, + Err(error) => { + assert!( + std::time::Instant::now() <= query_deadline, + "graph-off text projection never became queryable: {error}" + ); + tokio::task::yield_now().await; + } + } + }; + assert!( + executed.served_stale, + "an explicit overflow keeps the currently served text generation stale until reconcile settles" + ); + let overflow_deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let overflow_settled = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + scheduler.pending_hint_count() == Some(0) + && scheduler.last_reconciled_at_micros() != last_reconciled_before_overflow + }; + if overflow_settled + && !registry + .reconcile_in_progress_for_test(fixture.path()) + .await + { + break; + } + assert!( + std::time::Instant::now() <= overflow_deadline, + "graph-off overflow did not settle through a real no-op reconcile" + ); + tokio::task::yield_now().await; + } + let (owner_epoch_after_overflow, progress_after_overflow, decode_count) = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let decode_count = scheduler.sealed_decode_count(); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + ( + progress.owner_epoch, + progress + .snapshot() + .expect("ready text progress remains visible"), + decode_count, + ) + }; + assert_eq!(owner_epoch_after_overflow, owner_epoch_before_overflow); + assert_eq!( + progress_after_overflow.generation_id, + progress_before_overflow.generation_id + ); + assert_eq!( + progress_after_overflow.daemon_incarnation, + progress_before_overflow.daemon_incarnation + ); + assert_eq!( + progress_after_overflow.producer_incarnation, + progress_before_overflow.producer_incarnation + ); + assert!(progress_after_overflow.progress_epoch > progress_before_overflow.progress_epoch); + assert!(progress_after_overflow.committed_pages >= progress_before_overflow.committed_pages); + assert_eq!( + progress_after_overflow.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + ); + assert_eq!( + decode_count, 0, + "graph-off text serving must not decode the full generation" + ); + let artifact_names = std::fs::read_dir(super::code_text_artifacts_root( + &super::scoped_code_index_store_root(store.path(), fixture.path()), + )) + .expect("read text artifact root") + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + artifact_names + .iter() + .filter(|name| name.starts_with("text-artifact-") && name.ends_with(".bin")) + .count(), + 1, + "one durable artifact owns ready text serving" + ); + assert_eq!( + artifact_names + .iter() + .filter(|name| name.starts_with(".text-artifact-") && name.ends_with(".staging")) + .count(), + 0, + "ready text serving leaves no abandoned staging owner" + ); + assert_eq!( + executed + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::ExactLiteral), + Some(&PublicRetrieverStatus::Complete) + ); + assert_eq!( + executed + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::Lexical), + Some(&PublicRetrieverStatus::Complete) + ); + assert_eq!( + executed + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::Graph), + Some(&PublicRetrieverStatus::Unavailable) + ); + + let dashboard = registry + .dashboard_freshness(fixture.path()) + .await + .expect("graph-off dashboard freshness"); + assert_eq!(dashboard.staleness_state.as_deref(), Some("fresh")); + assert_eq!(dashboard.coverage, "complete"); + assert_eq!( + dashboard + .progress + .as_ref() + .expect("ready progress stays observable") + .phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + ); + let current = registry + .execute_query_search(&scope, core_search_request("alpha_0000")) + .await + .expect("settled graph-off text query"); + assert!( + !current.served_stale, + "a reconciled graph-off text owner must report current exact and lexical coverage" + ); + let semantic = registry + .execute_query_with_semantic( + fixture.path(), + &scope, + core_search_request("alpha_0000"), + Arc::new(ReadySemanticControlV1), + SemanticQueryModeV1::FallbackAllowed, + ) + .await + .expect("graph-off fallback semantic query"); + assert_eq!(semantic.query.generation, current.generation); + assert!(matches!( + semantic.semantic, + super::semantic_query_runtime::SemanticAugmentationOutcomeV1::Fallback { + abstention: SemanticAbstentionV1::CalibrationUnavailable, + .. + } + )); + let strict = registry + .execute_query_with_semantic( + fixture.path(), + &scope, + core_search_request("alpha_0000"), + Arc::new(ReadySemanticControlV1), + SemanticQueryModeV1::StrictSemantic, + ) + .await; + assert!(matches!( + strict, + Err( + super::semantic_query_runtime::QuerySemanticSearchExecutionErrorV1::StrictSemanticUnavailable { + generation, + abstention: SemanticAbstentionV1::CalibrationUnavailable, + } + ) if generation == current.generation + )); + assert_eq!( + scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .sealed_decode_count(), + 0, + "semantic fallback without an activated profile must not decode the sealed generation" + ); + let text = registry + .latest_text_serving_for_scope(&scope) + .await + .expect("ready graph-off text owner"); + let candidate = current + .authorized + .fallback + .ordered_candidates + .first() + .expect("artifact-backed ranked candidate"); + let (display, _) = crate::daemon::code_index_executor::code_index_text_search_display_binding( + &text, + current.sanitized.request(), + candidate, + ) + .expect("artifact-backed result display"); + assert_eq!(display.name, "alpha_0000"); + assert_eq!(display.kind, "function"); + assert_eq!(display.path, "src/file_0000.rs"); + assert_eq!( + scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .sealed_decode_count(), + 0, + "artifact-backed display hydration must not decode the sealed generation" + ); + + let query_scope = CodeQueryScope::new(executed.generation.clone(), None).expect("query scope"); + let exact_operation = + callable_code_operation(CallableCodeOperationKind::ExactOccurrence).expect("operation"); + let exact_context = application_context( + &exact_operation, + scope.repository_id.clone(), + scope.worktree_id.clone(), + ); + let exact_request = + ExactOccurrenceRequest::new("alpha_0000", None, query_scope.clone(), query_meta()) + .expect("exact request"); + let exact = registry + .exact_occurrence( + RetrievalPortContext { + request: &exact_context, + operation: &exact_operation, + }, + &exact_request, + ) + .await; + let exact_page = match exact { + RetrievalPortOutcome::Completed(evidence) => evidence.payload.expect("exact payload"), + outcome => panic!("ready graph-off exact owner was unavailable: {outcome:?}"), + }; + assert_eq!(exact_page.generation, executed.generation); + assert!( + exact_page + .items + .iter() + .any(|record| record.occurrence.path == "src/file_0000.rs"), + "artifact-backed exact hydration returns the canonical source path" + ); + + let phrase_operation = + callable_code_operation(CallableCodeOperationKind::PhraseSearch).expect("operation"); + let phrase_context = application_context( + &phrase_operation, + scope.repository_id.clone(), + scope.worktree_id.clone(), + ); + let query = EphemeralSanitizedQueryViewV1::sanitize( + "alpha_0000", + SanitizerRevision::new("sanitizer.query.fixture").expect("sanitizer"), + QueryNormalizationRevision::new("normalization.query.fixture").expect("normalization"), + ) + .expect("query"); + let phrase_request = PhraseSearchRequest::new( + query, + vec!["alpha_0000".to_owned()], + Vec::new(), + 0, + query_scope, + query_meta(), + ) + .expect("phrase request"); + let phrase = registry + .phrase_search( + RetrievalPortContext { + request: &phrase_context, + operation: &phrase_operation, + }, + &phrase_request, + ) + .await; + let phrase_page = match phrase { + RetrievalPortOutcome::Completed(evidence) => evidence.payload.expect("phrase payload"), + outcome => panic!("ready graph-off phrase owner was unavailable: {outcome:?}"), + }; + assert_eq!(phrase_page.generation, executed.generation); + assert!( + phrase_page + .items + .iter() + .any(|record| record.occurrence.path == "src/file_0000.rs"), + "artifact-backed lexical hydration returns the canonical source path" + ); + registry.shutdown().await; +} + +/// A graph-off changed-source rebuild must use the same canonical worker-memory +/// admission as the complete reconcile path. A denial occurs before capture, +/// leaves the durable pointer and hint authority intact, and releases its RAII +/// reservation so an adequately funded retry can publish without decoding A. +#[test] +fn graph_off_changed_source_worker_memory_denial_retries_without_decode() { + let fixture = GitFixture::new(&[( + "src/lib.rs", + "pub fn graph_off_memory_alpha() -> usize { 1 }\n", + )]); + let store = TempDir::new().expect("store root"); + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("seed generation A")); + let generation_a = scheduler + .publication + .read_publication_pointer() + .expect("read generation A pointer") + .expect("generation A pointer"); + let metadata_a = scheduler + .servable_retained_text_generation() + .expect("verified generation A text handle") + .metadata() + .clone(); + + fixture.edit( + "src/lib.rs", + "pub fn graph_off_memory_beta() -> usize { 2 }\n", + ); + git( + fixture.path(), + &["commit", "-qam", "publish memory generation B"], + ); + scheduler.notify_path(fixture.path().join("src/lib.rs")); + + let tight_limit = NonZeroU64::new(1024 * 1024).expect("tight canonical limit"); + assert!( + tracedecay_code_index::parallelism::worker_reservation_bytes( + tracedecay_code_index::parallelism::indexing_workers(), + ) > tight_limit.get(), + "the fixture limit must deny the installed worker plan" + ); + let tight = Arc::new(ProcessResidentMemoryV1::new(tight_limit)); + scheduler.bind_resident_memory(Arc::clone(&tight)); + let denied = scheduler.reconcile_retained_text_generation(&metadata_a); + assert!( + matches!( + denied, + Err(super::CodeIndexSchedulerErrorV1::WorkerMemoryAdmission(_)) + ), + "graph-off capture must be refused by canonical worker admission: {denied:?}" + ); + assert_eq!( + scheduler + .publication + .read_publication_pointer() + .expect("read pointer after denial") + .expect("active pointer after denial"), + generation_a, + "denied capture must leave generation A durable" + ); + assert_eq!( + tight.snapshot().used_bytes, + 0, + "a denied worker reservation must not leak a charge" + ); + + let adequate = Arc::new(ProcessResidentMemoryV1::new( + DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, + )); + scheduler.bind_resident_memory(Arc::clone(&adequate)); + let outcome = scheduler + .reconcile_retained_text_generation(&metadata_a) + .expect("adequately funded graph-off retry") + .expect("graph-off retry outcome"); + let generation_b = published(outcome); + assert_ne!( + generation_b.generation_id.as_str(), + generation_a.generation_id + ); + assert_eq!( + scheduler + .publication + .read_publication_pointer() + .expect("read generation B pointer") + .expect("generation B pointer") + .generation_id, + generation_b.generation_id.as_str() + ); + assert_eq!(scheduler.sealed_decode_count(), 0); + assert!( + adequate.snapshot().charges.iter().all(|charge| { + charge.key.component.as_str() != super::CODE_INDEX_WORKER_RESIDENT_COMPONENT_V1 + }), + "the successful retry must release its worker reservation" + ); +} + +/// Publishing a changed graph-off generation must hand text authority from the +/// ready prior generation to the new durable pointer. Otherwise the worker's +/// graph-only `latest` slot stays empty, neither serving swap runs, and queries +/// can serve the old generation forever even though the pointer names newer +/// source. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graph_off_changed_source_advances_text_authority_without_full_decode() { + let sources = (0..256) + .map(|index| { + ( + format!("src/file_{index:04}.rs"), + format!("pub fn alpha_{index:04}() -> usize {{ {index} }}\n"), + ) + }) + .collect::>(); + let source_refs = sources + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect::>(); + let fixture = GitFixture::new(&source_refs); + let store = TempDir::new().expect("store root"); + let scoped_store = super::scoped_code_index_store_root( + store.path(), + &fixture.path().canonicalize().expect("canonical fixture"), + ); + let (scope, privacy_domain) = { + let mut scheduler = scheduler( + &fixture, + scoped_store, + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("seed retained generation")); + let latest = scheduler.latest_complete().expect("seeded generation"); + let snapshot = latest.generation.snapshot(); + ( + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + snapshot.worktree.clone().expect("worktree id"), + snapshot.reference.clone(), + ) + .expect("resolved scope"), + latest.generation.manifest().privacy_domain.clone(), + ) + }; + + let registry = CodeIndexSchedulerRegistryV1::with_background_reconcile_permits(1, 1); + registry + .mount_worktree_with_graph_policy( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + super::CodeGraphActivationPolicyV1::RefusedByConfiguration, + ) + .await + .expect("mount graph-off scheduler"); + registry + .mount_query_authority(fixture.path(), &scope, query_authority(privacy_domain)) + .await + .expect("mount query authority"); + let scheduler = { + let mounted = registry.mounted.lock().await; + Arc::clone( + &mounted + .get(&fixture.path().canonicalize().expect("canonical root")) + .expect("mounted worktree") + .scheduler, + ) + }; + + let ready_deadline = std::time::Instant::now() + Duration::from_secs(10); + let generation_a = loop { + match registry + .execute_query_search(&scope, core_search_request("alpha_0000")) + .await + { + Ok(executed) => break executed.generation, + Err(error) => { + assert!( + std::time::Instant::now() <= ready_deadline, + "generation A never became text-queryable: {error}" + ); + tokio::task::yield_now().await; + } + } + }; + let owner_epoch_a = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(scheduler.sealed_decode_count(), 0); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!( + progress + .snapshot() + .expect("generation A ready progress") + .generation_id, + generation_a.as_str() + ); + progress.owner_epoch + }; + + fixture.edit( + "src/file_0000.rs", + "pub fn beta_0000() -> usize { 10_000 }\n", + ); + git(fixture.path(), &["commit", "-qam", "publish generation B"]); + let durable_generations_root = { + let mut scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let durable = scheduler.publication.generations_root.clone(); + let blocker = store.path().join("publication-root-blocker"); + std::fs::write(&blocker, b"not a directory").expect("write publication blocker"); + scheduler.publication.generations_root = blocker; + durable + }; + let reconcile_in_progress = { + Arc::clone( + &scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .reconcile_in_progress, + ) + }; + assert!( + registry + .notify_hook_paths(fixture.path(), &["src/file_0000.rs".to_owned()]) + .await, + "changed source wakes the mounted graph-off owner" + ); + + let attempt_deadline = std::time::Instant::now() + Duration::from_secs(10); + while reconcile_in_progress.load(std::sync::atomic::Ordering::Acquire) == 0 { + assert!( + std::time::Instant::now() <= attempt_deadline, + "transient publication failure was never attempted" + ); + tokio::task::yield_now().await; + } + let restore_deadline = std::time::Instant::now() + Duration::from_secs(10); + while reconcile_in_progress.load(std::sync::atomic::Ordering::Acquire) != 0 { + assert!( + std::time::Instant::now() <= restore_deadline, + "transient publication failure did not terminate" + ); + tokio::task::yield_now().await; + } + { + let mut scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!( + !scheduler + .hints + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .paths + .is_empty(), + "transient publication failure lost the drained source hint" + ); + assert_eq!( + scheduler + .publication + .read_publication_pointer() + .expect("read pointer after transient failure") + .expect("generation A remains durable") + .generation_id, + generation_a.as_str() + ); + assert_eq!(scheduler.sealed_decode_count(), 0); + scheduler.publication.generations_root = durable_generations_root; + } + assert!( + registry + .notify_hook_paths(fixture.path(), &["src/file_0000.rs".to_owned()]) + .await, + "retry wake reaches the restored source hint" + ); + + let publication_deadline = std::time::Instant::now() + Duration::from_secs(10); + let generation_b = loop { + let durable = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + scheduler + .publication + .read_publication_pointer() + .expect("read durable active pointer") + .map(|pointer| pointer.generation_id) + }; + if let Some(durable) = durable + && durable != generation_a.as_str() + { + break CodeGenerationId::new(durable).expect("generation B id"); + } + assert!( + std::time::Instant::now() <= publication_deadline, + "changed graph-off source never published durable generation B" + ); + tokio::task::yield_now().await; + }; + + let progress_deadline = std::time::Instant::now() + Duration::from_secs(10); + let progress_b = loop { + let progress = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (progress.owner_epoch, progress.snapshot()) + }; + if let (owner_epoch, Some(progress)) = progress + && progress.generation_id == generation_b.as_str() + && progress.committed_pages > 0 + { + assert!(owner_epoch > owner_epoch_a); + break progress; + } + assert!( + std::time::Instant::now() <= progress_deadline, + "durable generation B never acquired advancing text authority" + ); + tokio::task::yield_now().await; + }; + assert!(progress_b.progress_epoch > 0); + assert_eq!( + registry.latest_generation_id(fixture.path()).await, + Some(generation_b.clone()), + "generation A stops owning the graph-off query route" + ); + if let Ok(executed) = registry + .execute_query_search(&scope, core_search_request("alpha_0000")) + .await + { + assert_ne!( + executed.generation, generation_a, + "generation A must not serve after B owns text progress" + ); + } + + let query_deadline = std::time::Instant::now() + Duration::from_secs(10); + let executed_b = loop { + match registry + .execute_query_search(&scope, core_search_request("beta_0000")) + .await + { + Ok(executed) if executed.generation == generation_b => break executed, + Ok(executed) => panic!( + "stale generation {} served after durable B", + executed.generation + ), + Err(error) => { + assert!( + std::time::Instant::now() <= query_deadline, + "generation B never became text-queryable: {error}" + ); + tokio::task::yield_now().await; + } + } + }; + assert_eq!( + executed_b + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::ExactLiteral), + Some(&PublicRetrieverStatus::Complete) + ); + assert_eq!( + executed_b + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::Lexical), + Some(&PublicRetrieverStatus::Complete) + ); + assert_eq!( + executed_b + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::Graph), + Some(&PublicRetrieverStatus::Unavailable) + ); + assert_eq!( + scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .sealed_decode_count(), + 0, + "graph-off A-to-B publication must not decode a sealed generation" + ); + registry.shutdown().await; +} + +/// The canonical project setting must reach the scheduler's production policy +/// boundary. A configured refusal still verifies and seats the sealed text +/// generation, while the graph authority is never activated. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pinned_configuration_refuses_native_graph_before_text_serving_swap() { + let fixture = GitFixture::new(ALPHA_LIB_V1); + let store = TempDir::new().expect("store root"); + let scoped_store = super::scoped_code_index_store_root( + store.path(), + &fixture.path().canonicalize().expect("canonical fixture"), + ); + let scope = { + let mut scheduler = scheduler( + &fixture, + scoped_store, + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("seed generation")); + let latest = scheduler.latest_complete().expect("seeded generation"); + let snapshot = latest.generation.snapshot(); + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + snapshot.worktree.clone().expect("worktree id"), + snapshot.reference.clone(), + ) + .expect("resolved scope") + }; + + let configuration_registry = + crate::config::registry::ConfigurationRegistry::core().expect("configuration registry"); + let setting = tracedecay_domain::configuration::SettingKey::new( + tracedecay_domain::configuration::INDEX_NATIVE_GRAPH_ACTIVATION_SETTING_KEY, + ) + .expect("native graph setting key"); + let layer = crate::config::resolver::ConfigurationLayerV1 { + layer: tracedecay_domain::configuration::ConfigurationLayerIdV1::Project { + project_id: test_project_id(), + }, + revision_id: tracedecay_domain::configuration::ConfigurationRevisionId::new( + "revision.native-graph-refusal.1", + ) + .expect("configuration revision"), + entries: std::collections::BTreeMap::from([( + setting, + tracedecay_domain::configuration::ConfigurationValueV1::Boolean(false), + )]), + }; + let snapshot = + crate::config::resolver::resolve_configuration(&configuration_registry, &[layer]) + .expect("resolve configured native graph refusal") + .snapshot; + let config = tracedecay_usecases::config::PinnedRuntimeConfiguration::new( + tracedecay_usecases::config::RuntimeConfigurationTarget { + project_id: test_project_id(), + project_root: fixture.path().to_path_buf(), + }, + tracedecay_domain::configuration::ConfigurationRevisionId::new( + "revision.native-graph-refusal.1", + ) + .expect("pinned configuration revision"), + snapshot, + ) + .expect("materialize pinned runtime configuration"); + assert!(!config.config.native_graph_activation); + + let registry = CodeIndexSchedulerRegistryV1::with_background_reconcile_permits(1, 1); + registry + .mount_worktree_with_graph_policy( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + super::CodeGraphActivationPolicyV1::from_enabled(config.config.native_graph_activation), + ) + .await + .expect("mount scheduler under configured graph policy"); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let latest = loop { + if let Some(latest) = registry.latest_complete_serving_for_scope(&scope).await { + break latest; + } + assert!( + std::time::Instant::now() <= deadline, + "configured graph refusal withheld the text-serving generation" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + }; + registry + .mount_query_authority( + fixture.path(), + &scope, + query_authority(latest.generation.manifest().privacy_domain.clone()), + ) + .await + .expect("mount retained query authority"); + let text_deadline = std::time::Instant::now() + Duration::from_secs(5); + while !latest.query_owners_are_warm() { + assert!( + std::time::Instant::now() <= text_deadline, + "background text projection did not complete across bounded worker windows" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + let executed = registry + .execute_query_search(&scope, core_search_request("alpha")) + .await + .expect("configured refusal preserves exact and lexical query serving"); + assert_eq!( + executed + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::ExactLiteral), + Some(&PublicRetrieverStatus::Complete) ); assert_eq!( executed @@ -9442,9 +10978,10 @@ async fn same_root_remount_updates_retained_graph_policy_before_worker_activatio tokio::time::sleep(Duration::from_millis(10)).await; }; assert!( - !latest.query_owners_are_warm(), - "same-root refusal was lost and entered retained owner hydration" + latest.query_owners_are_warm(), + "same-root graph refusal must preserve retained text hydration" ); + assert!(latest.production_query_owners().is_ok()); assert!(latest.production_graph_serving().is_err()); registry.shutdown().await; } @@ -9463,17 +11000,23 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese store.path(), &fixture.path().canonicalize().expect("canonical fixture"), ); - let sealed_worktree_id = { + let (scope, sealed_worktree_id, sealed_generation_id) = { let mut scheduler = scheduler(&fixture, scoped_store.clone(), bytes); published(scheduler.reconcile_now().expect("seed generation")); - scheduler - .latest_complete() - .expect("seeded generation") - .generation - .snapshot() - .worktree - .clone() - .expect("seeded worktree id") + let latest = scheduler.latest_complete().expect("seeded generation"); + let snapshot = latest.generation.snapshot(); + let worktree_id = snapshot.worktree.clone().expect("seeded worktree id"); + ( + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + worktree_id.clone(), + snapshot.reference.clone(), + ) + .expect("resolved scope"), + worktree_id, + latest.generation.manifest().generation_id.clone(), + ) }; // Change the worktree so a reconcile pass would seal a brand-new // generation if the worker fell through after the activation failure. @@ -9516,10 +11059,32 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese 1, "a retryable activation failure must not seal a duplicate generation" ); + let text_deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if registry + .latest_text_serving_for_scope(&scope) + .await + .is_some() + { + break; + } + assert!( + std::time::Instant::now() <= text_deadline, + "graph retry backoff withheld the retained text generation" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } assert_eq!( registry.latest_generation_id(fixture.path()).await, - None, - "a generation that never activated must not serve" + Some(sealed_generation_id), + "exact and lexical serving remains authoritative during graph retry" + ); + assert!( + registry + .latest_complete_serving_for_scope(&scope) + .await + .is_none(), + "retryable graph activation must not expose an unactivated graph owner" ); // Clearing the injected failure lets the scheduled backoff retry activate @@ -9528,7 +11093,7 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { if registry - .latest_generation_id(fixture.path()) + .latest_complete_serving_for_scope(&scope) .await .is_some() { @@ -9627,6 +11192,86 @@ async fn wait_for_event_to_ready( } } +/// A successful reconcile is product state; optional lifecycle telemetry may +/// not keep its in-progress guard or freshness response open while the +/// observability store is busy. +#[tokio::test] +async fn blocked_observability_store_does_not_hold_reconcile_readiness() { + let _pin = crate::config::PinnedUserDataDir::new(); + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let store = TempDir::new().expect("store root"); + let (registry, scope) = mounted_core_query_worktree(&fixture, &store).await; + let runtime = tracedecay_global_db::tests::harness::RegisteredGlobalDbTestRuntime::project( + tracedecay_runtime_core::storage::default_profile_root().expect("profile root"), + fixture.path(), + scope.project_id.clone(), + ) + .await + .expect("registered runtime"); + let database = runtime.project_database_arc().expect("project database"); + let producer = Arc::new( + tracedecay_usecases::observability::BoundedObservabilityProducerV1::start( + database.clone(), + tracedecay_usecases::observability::ObservabilityProducerIdentityV1 { + authorized_scope_ref: scope.project_id.as_str().to_owned(), + process_boot_id: "boot:code-index-readiness".to_owned(), + producer_revision: "code-index-readiness-test.v1".to_owned(), + configuration_revision: "code-index-readiness-config.v1".to_owned(), + policy_revision: "code-index-readiness-policy.v1".to_owned(), + }, + 8, + ) + .expect("bounded producer"), + ); + registry + .install_index_observability( + fixture.path(), + super::observability::CodeIndexObservabilityV1::new(Arc::clone(&producer)), + ) + .await + .expect("install observability lane"); + + let blocked_writer = database + .begin_write_transaction() + .await + .expect("hold observability writer"); + let initial = registry + .latest_generation_id(fixture.path()) + .await + .expect("initial generation"); + fixture.edit("src/lib.rs", "pub fn alpha() -> u32 { 2 }\n"); + assert!( + registry + .notify_path(fixture.path(), fixture.path().join("src/lib.rs")) + .await + ); + let _ = wait_for_generation_change(®istry, fixture.path(), &initial).await; + + let deadline = std::time::Instant::now() + Duration::from_secs(1); + loop { + let freshness = registry + .dashboard_freshness(fixture.path()) + .await + .expect("dashboard freshness"); + if freshness.staleness_state.as_deref() == Some("fresh") && freshness.coverage == "complete" + { + break; + } + assert!( + std::time::Instant::now() <= deadline, + "optional telemetry held successful reconcile readiness: {freshness:?}" + ); + tokio::task::yield_now().await; + } + + blocked_writer + .commit() + .await + .expect("release observability writer"); + registry.shutdown().await; + producer.shutdown().await.expect("flush producer"); +} + /// The installed observability lane must persist one canonical index /// lifecycle observation when a reconcile publishes a generation, and the /// retrieval-pipeline families when a query composition completes, all in the @@ -9663,10 +11308,7 @@ async fn installed_observability_lane_records_index_and_retrieval_observations() registry .install_index_observability( fixture.path(), - super::observability::CodeIndexObservabilityV1::new( - database.clone(), - Arc::clone(&producer), - ), + super::observability::CodeIndexObservabilityV1::new(Arc::clone(&producer)), ) .await .expect("install observability lane"); @@ -9685,6 +11327,22 @@ async fn installed_observability_lane_records_index_and_retrieval_observations() ); let _ = wait_for_generation_change(®istry, fixture.path(), &initial).await; + let ready_deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if registry + .latest_text_serving_for_scope(&scope) + .await + .is_some() + { + break; + } + assert!( + std::time::Instant::now() <= ready_deadline, + "published text generation did not become queryable" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + // One real query composition through the mounted authority carries the // retrieval-pipeline families through the bounded producer. let _executed = registry diff --git a/src/daemon/engine.rs b/src/daemon/engine.rs index d7d23231c1..f1a392b605 100644 --- a/src/daemon/engine.rs +++ b/src/daemon/engine.rs @@ -136,6 +136,12 @@ pub(super) fn ensure_context_scout_owner_before_advertising( #[cfg(unix)] impl DaemonEngine { + pub(super) fn with_progress_producer_incarnation(mut self, producer_incarnation: u64) -> Self { + self.invocation = + DaemonInvocationState::with_progress_producer_incarnation(producer_incarnation); + self + } + pub(super) fn with_profile_identity( mut self, profile_identity: profile_identity::LocalProfileIdentityAuthorityV1, diff --git a/src/daemon/invocation_state.rs b/src/daemon/invocation_state.rs index f6fc9f2070..fb28f63003 100644 --- a/src/daemon/invocation_state.rs +++ b/src/daemon/invocation_state.rs @@ -41,14 +41,22 @@ pub(crate) struct DaemonInvocationState { impl Default for DaemonInvocationState { fn default() -> Self { + Self::with_progress_producer_incarnation(1) + } +} + +impl DaemonInvocationState { + /// Construct one daemon-generation invocation state whose dashboard + /// progress is ordered by the existing durable daemon-authority epoch. + pub(super) fn with_progress_producer_incarnation(producer_incarnation: u64) -> Self { let resident_memory = Arc::new(ProcessResidentMemoryV1::new( DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, )); - let code_index_schedulers = - code_index_scheduler::CodeIndexSchedulerRegistryV1::with_resident_memory( - MAX_CACHED_PROJECT_SERVERS, - Arc::clone(&resident_memory), - ); + let code_index_schedulers = code_index_scheduler::CodeIndexSchedulerRegistryV1::with_resident_memory_and_progress_producer_incarnation( + MAX_CACHED_PROJECT_SERVERS, + Arc::clone(&resident_memory), + producer_incarnation, + ); let service = DaemonInvocationService::with_code_index_schedulers(code_index_schedulers.clone()); let query_authority_provider = @@ -71,9 +79,7 @@ impl Default for DaemonInvocationState { tracedecay_usecases::semantic_runtime::DaemonGlobalSemanticProjectionSchedulerV1::default(), } } -} -impl DaemonInvocationState { pub(super) fn invocation_service(&self) -> DaemonInvocationService { self.service.clone() } @@ -463,16 +469,16 @@ impl DaemonInvocationState { // observability lane uninstalled and nothing records. match self .service - .observability_producer_with_database(Some(&canonical_project_root)) + .observability_producer(Some(&canonical_project_root)) .await { - Some((session_db, producer)) => { + Some(producer) => { if let Err(error) = self .code_index_schedulers .install_index_observability( &canonical_project_root, code_index_scheduler::observability::CodeIndexObservabilityV1::new( - session_db, producer, + producer, ), ) .await diff --git a/src/daemon/production_harness/generation_retention_test.rs b/src/daemon/production_harness/generation_retention_test.rs index 8bb2294693..06709fd4a8 100644 --- a/src/daemon/production_harness/generation_retention_test.rs +++ b/src/daemon/production_harness/generation_retention_test.rs @@ -622,7 +622,7 @@ fn age_scope_for_reconciliation(scope: &Path) { async fn linked_worktree_scope_retention_crash_replay_and_pure_inventory_journey() { use super::semantic_activation_journey_test::{ evaluate_native_profile, installed_selection_material, seed_distribution_fixture, - selection, semantic_candidate, set_semantic_profile, wait_for_semantic_generation, + selection, set_semantic_profile, wait_for_semantic_generation, }; let fixture_root = std::env::var_os("TRACEDECAY_DISTRIBUTION_FASTEMBED_FIXTURE") @@ -689,18 +689,8 @@ async fn linked_worktree_scope_retention_crash_replay_and_pure_inventory_journey primary_vector.generation_id(), linked_vector.generation_id() ); - let primary_profile = evaluate_native_profile( - &harness, - &primary, - semantic_candidate(&primary_code, &primary_vector), - ) - .await; - let linked_profile = evaluate_native_profile( - &harness, - &linked, - semantic_candidate(&linked_code, &linked_vector), - ) - .await; + let primary_profile = evaluate_native_profile(&harness, &primary).await; + let linked_profile = evaluate_native_profile(&harness, &linked).await; let primary_selection = selection(primary_profile, &artifact_digest, &artifact_path); let linked_selection = selection(linked_profile, &artifact_digest, &artifact_path); set_semantic_profile( diff --git a/src/daemon/production_harness/semantic_activation_journey_test.rs b/src/daemon/production_harness/semantic_activation_journey_test.rs index 17a4faf898..0ee8157016 100644 --- a/src/daemon/production_harness/semantic_activation_journey_test.rs +++ b/src/daemon/production_harness/semantic_activation_journey_test.rs @@ -7,18 +7,8 @@ use std::time::Duration; use serde_json::{Value, json}; use tracedecay_application::ConfigurationSetRequestV1; use tracedecay_domain::configuration::{ConfigurationLayerIdV1, ConfigurationValueV1, SettingKey}; -use tracedecay_domain::{ - CalibrationProfileId, ComponentRevision, ManifestDigest, SemanticSearchIndexProfileV1, - VectorGenerationIdV1, canonical_sha256, -}; -use tracedecay_query::retrieval::semantic::SemanticCalibrationProfileV1; -use tracedecay_usecases::config::retrieval::{ - RetrievalCompatibilityPinsV1, SemanticCompatibilityPinsV1, SemanticResourceRequirementV1, -}; -use tracedecay_usecases::semantic_runtime::{ - SemanticEvaluationDiversityCandidateV1, SemanticEvaluationFusionCandidateV1, - SemanticEvaluationProfileCandidateV1, SemanticRuntimeStateV1, -}; +use tracedecay_domain::{ManifestDigest, VectorGenerationIdV1}; +use tracedecay_usecases::semantic_runtime::SemanticRuntimeStateV1; use tracedecay_usecases::store::vector_generations::{ GraphVectorGenerationStoreV1, PublishedVectorGenerationV1, }; @@ -228,127 +218,15 @@ pub(super) async fn wait_for_semantic_generation( .expect("production semantic generation did not publish") } -pub(super) fn semantic_candidate( - code: &tracedecay_code_index::production::CodeIndexPublishedGenerationV1, - vector: &PublishedVectorGenerationV1, -) -> SemanticEvaluationProfileCandidateV1 { - let material = - crate::search_eval::load_default_evaluated_profile_material(EVALUATED_PROFILE_ID) - .expect("checked-in evaluated profile material"); - let embedding = vector.embedding_key().embedding_key(); - let runtime_compatibility_digest = canonical_sha256(&( - "tracedecay.semantic-runtime-compatibility.v1", - &embedding.runtime_backend, - &embedding.runtime_build_revision, - embedding.device_class, - embedding.precision, - )) - .expect("runtime compatibility digest"); - let search_index_key = SemanticSearchIndexProfileV1::exact_flat_v1() - .and_then(|profile| profile.index_key()) - .expect("production exact-flat semantic index"); - // These bound the evaluation execution only. The accepted profile is - // rebound to the evaluator's exact current/10x observations before it is - // persisted or becomes activation-eligible. - let configured_limits = crate::config::SemanticResourceCeilings::default(); - let catalog = crate::semantic_code::production_fastembed_catalog(); - let cataloged_model = catalog - .get(crate::semantic_code::DEFAULT_FASTEMBED_MODEL_ID) - .expect("production catalog contains default model"); - let model_bytes = cataloged_model - .members - .get("model") - .expect("production model member") - .length; - let tokenizer_bytes = cataloged_model - .members - .get("tokenizer") - .expect("production tokenizer member") - .length; - let vector_generation_id = vector.generation_id().clone(); - let calibration = SemanticCalibrationProfileV1 { - calibration_profile_id: CalibrationProfileId::new("calibration.semantic.runtime.v1") - .expect("calibration profile id"), - cohort_digest: canonical_sha256(&( - "tracedecay.semantic.evaluation-calibration-cohort.v1", - code.manifest().generation_id.clone(), - vector.source_manifest_digest().clone(), - code.capability().manifest_digest.clone(), - vector.embedding_key().clone(), - vector_generation_id.clone(), - embedding.model_artifact_digest.clone(), - )) - .expect("calibration cohort digest"), - projection_key: vector.projection_key().clone(), - vector_generation: vector_generation_id.clone(), - capability_manifest_digest: code.capability().manifest_digest.clone(), - maximum_distance_micros: i64::MAX, - minimum_margin_micros: 0, - }; - SemanticEvaluationProfileCandidateV1 { - evaluated_profile_id: EVALUATED_PROFILE_ID.to_owned(), - profile: SemanticEvaluationFusionCandidateV1 { - profile_id: material.profile.profile_id.clone(), - calibrations: material.profile.calibrations.clone(), - score_domain_calibrations: material.profile.score_domain_calibrations.clone(), - weights_micros: material.profile.weights_micros.clone(), - diversity_policy_id: material.profile.diversity_policy_id.clone(), - rerank_policy_id: material.profile.rerank_policy_id.clone(), - retrieval_budget: material.profile.retrieval_budget, - }, - diversity: SemanticEvaluationDiversityCandidateV1 { - policy_id: material.diversity.policy_id.clone(), - per_source_namespace: material.diversity.per_source_namespace, - per_source_instance: material.diversity.per_source_instance, - per_repository: material.diversity.per_repository, - per_file: material.diversity.per_file, - per_session_or_thread: material.diversity.per_session_or_thread, - per_copy_cluster: material.diversity.per_copy_cluster, - per_evidence_role: material.diversity.per_evidence_role, - }, - rerank: None, - compatibility: RetrievalCompatibilityPinsV1 { - semantic: Some(SemanticCompatibilityPinsV1 { - implementation_revision: ComponentRevision::new("semantic.fastembed.production.v1") - .expect("semantic implementation revision"), - fusion_revision: ComponentRevision::new( - tracedecay_query::retrieval::QUERY_RANKING_REVISION_V1, - ) - .expect("fusion revision"), - artifact_manifest_digest: embedding.model_artifact_digest.clone(), - runtime_compatibility_digest, - projection: vector.embedding_key().clone(), - search_index_key, - vector_generation_id, - calibration, - resources: SemanticResourceRequirementV1 { - model_bytes, - tokenizer_bytes, - resident_bytes: configured_limits.max_resident_bytes, - threads: configured_limits.max_threads, - max_concurrent_sessions: configured_limits.max_concurrent_sessions, - batch_size: configured_limits.max_batch_size, - sequence_length: configured_limits.max_sequence_length, - load_deadline_ms: configured_limits.load_deadline_ms, - }, - }), - rerank: None, - }, - } -} - pub(super) async fn evaluate_native_profile( harness: &ProductionProjectCompositionHarnessV1, project: &Path, - candidate: SemanticEvaluationProfileCandidateV1, ) -> ManifestDigest { let resources = harness.resources.as_ref().expect("live harness"); - let evaluation_limits = candidate - .compatibility - .semantic - .as_ref() - .expect("semantic evaluation limits") - .resources; + let evaluation_limits = + crate::daemon::semantic_evaluation::daemon_semantic_evaluation_resource_requirement( + crate::config::SemanticResourceCeilings::default(), + ); let observed_at = tracedecay_domain::UtcMicros( i64::try_from( std::time::SystemTime::now() @@ -377,7 +255,7 @@ pub(super) async fn evaluate_native_profile( None, crate::daemon_contract::DaemonInvocationRequest::semantic_evaluate_and_publish( request_id.as_str(), - candidate, + EVALUATED_PROFILE_ID.to_owned(), observed_at, tracedecay_application::Deadline::new(tracedecay_domain::UtcMicros( observed_at.0 @@ -737,12 +615,7 @@ async fn public_semantic_activation_rollback_and_exact_retry_preserve_graph_auth first_vector.generation_id().clone(), )]; let graph_before_first_evaluation = graph_bytes(&harness, &project, &first_generation).await; - let first_profile = evaluate_native_profile( - &harness, - &project, - semantic_candidate(&first_code, &first_vector), - ) - .await; + let first_profile = evaluate_native_profile(&harness, &project).await; assert_eq!( graph_bytes(&harness, &project, &first_generation).await, graph_before_first_evaluation, @@ -813,12 +686,7 @@ async fn public_semantic_activation_rollback_and_exact_retry_preserve_graph_auth ), ]; let graph_before_second_evaluation = graph_bytes(&harness, &project, &generations).await; - let second_profile = evaluate_native_profile( - &harness, - &project, - semantic_candidate(&second_code, &second_vector), - ) - .await; + let second_profile = evaluate_native_profile(&harness, &project).await; assert_eq!( graph_bytes(&harness, &project, &generations).await, graph_before_second_evaluation, diff --git a/src/daemon/production_harness/semantic_availability_journey_test.rs b/src/daemon/production_harness/semantic_availability_journey_test.rs index db0350c66e..ab286d1f10 100644 --- a/src/daemon/production_harness/semantic_availability_journey_test.rs +++ b/src/daemon/production_harness/semantic_availability_journey_test.rs @@ -18,8 +18,7 @@ use serde_json::{Value, json}; use super::journey_test_support::{git, tool_payload}; use super::semantic_activation_journey_test::{ assert_semantic_probe_contribution, evaluate_native_profile, installed_selection_material, - seed_distribution_fixture, selection, semantic_candidate, set_semantic_profile, - wait_for_semantic_generation, + seed_distribution_fixture, selection, set_semantic_profile, wait_for_semantic_generation, }; use super::*; @@ -426,8 +425,7 @@ async fn retrieval_answers_before_activation_and_is_unchanged_by_live_semantic_a ); // ---- Phase 2: the real accepted-profile evaluation. ------------------- - let accepted_profile = - evaluate_native_profile(&harness, &project, semantic_candidate(&code, &vector)).await; + let accepted_profile = evaluate_native_profile(&harness, &project).await; assert_eq!( non_semantic_answers(&harness, &project).await, answers_before, diff --git a/src/daemon/project_open_owners/query_authority_upgrade.rs b/src/daemon/project_open_owners/query_authority_upgrade.rs index 149bef9d88..c6b34a2eeb 100644 --- a/src/daemon/project_open_owners/query_authority_upgrade.rs +++ b/src/daemon/project_open_owners/query_authority_upgrade.rs @@ -1,13 +1,14 @@ //! Deferred query-authority mounts covering the cold-open generation gap. //! //! Both project-open query-authority mounts resolve the privacy domain from an -//! already-complete current code generation, but a fresh project publishes its +//! authenticated current text generation, but a fresh project publishes its //! first generation asynchronously after admission, so the open-time mount can //! lose that race. A physical restart restores a sealed generation as `Noop` -//! and does not republish, so waiters must also poll the serving slot: a -//! publication that will never repeat must not deny search for the rest of the -//! daemon session. Retry the exact mount that failed when this project's -//! generation becomes serving, mirroring the deferred feedback-cycle upgrade. +//! and does not republish, so waiters must also poll the text-serving slot: a +//! publication that will never repeat must not deny exact/lexical search while +//! optional graph activation is still warming. Retry the exact mount that +//! failed when this project's text generation becomes serving, mirroring the +//! deferred feedback-cycle upgrade. use std::path::{Path, PathBuf}; use tracedecay_application::ResolvedScope; @@ -29,9 +30,9 @@ pub(super) enum DeferredQueryAuthorityMountV1 { }, } -/// Waits for the first complete code-index generation of `project_root` and -/// then retries the query-authority mount. Exits when the mount reaches any -/// terminal outcome or the publication channel closes (daemon shutdown). +/// Waits for the first authenticated text generation of `project_root` and then +/// retries the query-authority mount. Exits when the mount reaches any terminal +/// outcome or the publication channel closes (daemon shutdown). /// /// The open-time mount runs before code-index activation, so the first ready /// check usually misses. A later `Published` event wakes the waiter on a @@ -53,7 +54,7 @@ pub(super) fn spawn_deferred_query_authority_mount( loop { if invocation .code_index_schedulers - .latest_complete_ready_for_scope(&scope) + .latest_text_serving_for_scope(&scope) .await .is_some() && try_deferred_mount(&invocation, &project_root, &scope, &mount).await diff --git a/src/daemon/semantic_evaluation.rs b/src/daemon/semantic_evaluation.rs index a3a14c5304..3761e3c131 100644 --- a/src/daemon/semantic_evaluation.rs +++ b/src/daemon/semantic_evaluation.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; use std::future::Future; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; #[cfg(test)] @@ -8,10 +8,17 @@ use std::time::Duration; use tokio::task::JoinHandle; use tracedecay_application::ResolvedScope; -use tracedecay_domain::CodeGenerationId; +use tracedecay_domain::{ + CalibrationProfileId, CodeGenerationId, ComponentRevision, SemanticSearchIndexProfileV1, + VectorGenerationIdV1, canonical_sha256, +}; +use tracedecay_query::retrieval::semantic::SemanticCalibrationProfileV1; use tracedecay_runtime_core::cancellation::CancellationToken; use crate::config::retrieval::RetrievalRuntimeCompatibilityV1; +use crate::config::retrieval::{ + RetrievalCompatibilityPinsV1, SemanticCompatibilityPinsV1, SemanticResourceRequirementV1, +}; use crate::search_eval::semantic_native::{ SemanticNativePendingReasonV1, SemanticNativeResourceProvenanceV1, SemanticNativeResourceSampleV1, SemanticNativeStageResultV1, SemanticProjectionCaseSampleV1, @@ -29,6 +36,9 @@ use tracedecay_usecases::semantic_runtime::{ SemanticEvaluationPublicationSnapshotV1, SemanticEvaluationSnapshotPortV1, SemanticRuntimeBackendErrorV1, SemanticRuntimeFuture, }; +use tracedecay_usecases::store::vector_generations::{ + GraphVectorGenerationStoreV1, PublishedVectorGenerationV1, +}; use super::code_index_scheduler::CodeIndexSchedulerRegistryV1; @@ -127,7 +137,7 @@ impl DaemonSemanticEvaluationControlV1 { .map_err(|_| SemanticActivationCoordinationErrorV1::Unavailable) } - async fn interruptible( + pub(super) async fn interruptible( &self, operation: impl Future, ) -> Result { @@ -198,6 +208,187 @@ fn coordination_error_from_runtime( } } +pub(super) async fn build_daemon_semantic_evaluation_candidate( + project_root: &Path, + scope: &ResolvedScope, + scheduler: &CodeIndexSchedulerRegistryV1, + evaluated_profile_id: &str, + configured_limits: crate::config::SemanticResourceCeilings, + control: Arc, +) -> Result { + control.checkpoint()?; + let snapshot = control + .interruptible(scheduler.semantic_evaluation_snapshot_for_scope(scope)) + .await? + .ok_or(SemanticActivationCoordinationErrorV1::Unavailable)?; + let serving = control + .interruptible(scheduler.serving_code_scope(project_root)) + .await? + .ok_or(SemanticActivationCoordinationErrorV1::Unavailable)?; + let code = serving + .serving_generation + .ok_or(SemanticActivationCoordinationErrorV1::Unavailable)?; + if code.manifest().generation_id != snapshot.source_generation + || code.projection().request().changes.manifest_digest != snapshot.source_manifest_digest + || code.manifest().snapshot_digest != snapshot.snapshot_digest + || code.capability().manifest_digest != snapshot.capability_manifest_digest + { + return Err(SemanticActivationCoordinationErrorV1::Conflict); + } + + let status = tracedecay_usecases::semantic_runtime::project_semantic_application_status( + project_root, + None, + ) + .ok_or(SemanticActivationCoordinationErrorV1::Unavailable)?; + let vector_generation_id = semantic_publication_generation(&status.state)?; + let provider = control + .interruptible(scheduler.semantic_vector_graph_provider(project_root)) + .await? + .ok_or(SemanticActivationCoordinationErrorV1::Unavailable)?; + let retained = control + .interruptible(provider.graph_for_generation(&code)) + .await? + .map_err(|_| SemanticActivationCoordinationErrorV1::Unavailable)?; + let store = + GraphVectorGenerationStoreV1::read_only_generation(&retained, &vector_generation_id) + .map_err(|_| SemanticActivationCoordinationErrorV1::Unavailable)? + .ok_or(SemanticActivationCoordinationErrorV1::Conflict)?; + let vector = control + .interruptible(store.generation(&vector_generation_id, Arc::clone(retained.cancellation()))) + .await? + .map_err(|_| SemanticActivationCoordinationErrorV1::Unavailable)? + .ok_or(SemanticActivationCoordinationErrorV1::Conflict)?; + if vector.source_generation() != &snapshot.source_generation + || vector.source_manifest_digest() != &snapshot.source_manifest_digest + { + return Err(SemanticActivationCoordinationErrorV1::Conflict); + } + daemon_semantic_evaluation_candidate(evaluated_profile_id, &code, &vector, configured_limits) +} + +pub(super) fn semantic_publication_generation( + state: &tracedecay_usecases::semantic_runtime::SemanticRuntimeStateV1, +) -> Result { + use tracedecay_usecases::semantic_runtime::{SemanticFallbackReasonV1, SemanticRuntimeStateV1}; + + match state { + SemanticRuntimeStateV1::Current { receipt } => Ok(receipt.activated_generation.clone()), + SemanticRuntimeStateV1::Degraded { + active_generation: Some(_), + reason: SemanticFallbackReasonV1::Stale, + } + | SemanticRuntimeStateV1::Rollback { .. } => { + Err(SemanticActivationCoordinationErrorV1::Conflict) + } + SemanticRuntimeStateV1::Degraded { + active_generation: Some(generation), + .. + } => Ok(generation.clone()), + _ => Err(SemanticActivationCoordinationErrorV1::Unavailable), + } +} + +fn daemon_semantic_evaluation_candidate( + evaluated_profile_id: &str, + code: &tracedecay_code_index::production::CodeIndexPublishedGenerationV1, + vector: &PublishedVectorGenerationV1, + configured_limits: crate::config::SemanticResourceCeilings, +) -> Result { + let material = + crate::search_eval::load_default_evaluated_profile_material(evaluated_profile_id) + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?; + let embedding = vector.embedding_key().embedding_key(); + let runtime_compatibility_digest = canonical_sha256(&( + "tracedecay.semantic-runtime-compatibility.v1", + &embedding.runtime_backend, + &embedding.runtime_build_revision, + embedding.device_class, + embedding.precision, + )) + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?; + let search_index_key = SemanticSearchIndexProfileV1::exact_flat_v1() + .and_then(|profile| profile.index_key()) + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?; + let resources = daemon_semantic_evaluation_resource_requirement(configured_limits); + let vector_generation_id = vector.generation_id().clone(); + let calibration = SemanticCalibrationProfileV1 { + calibration_profile_id: CalibrationProfileId::new("calibration.semantic.runtime.v1") + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?, + cohort_digest: canonical_sha256(&( + "tracedecay.semantic.evaluation-calibration-cohort.v1", + code.manifest().generation_id.clone(), + vector.source_manifest_digest().clone(), + code.capability().manifest_digest.clone(), + vector.embedding_key().clone(), + vector_generation_id.clone(), + embedding.model_artifact_digest.clone(), + )) + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?, + projection_key: vector.projection_key().clone(), + vector_generation: vector_generation_id.clone(), + capability_manifest_digest: code.capability().manifest_digest.clone(), + maximum_distance_micros: i64::MAX, + minimum_margin_micros: 0, + }; + Ok(SemanticEvaluationProfileCandidateV1 { + evaluated_profile_id: evaluated_profile_id.to_owned(), + profile: tracedecay_usecases::semantic_runtime::SemanticEvaluationFusionCandidateV1 { + profile_id: material.profile.profile_id.clone(), + calibrations: material.profile.calibrations.clone(), + score_domain_calibrations: material.profile.score_domain_calibrations.clone(), + weights_micros: material.profile.weights_micros.clone(), + diversity_policy_id: material.profile.diversity_policy_id.clone(), + rerank_policy_id: material.profile.rerank_policy_id.clone(), + retrieval_budget: material.profile.retrieval_budget, + }, + diversity: tracedecay_usecases::semantic_runtime::SemanticEvaluationDiversityCandidateV1 { + policy_id: material.diversity.policy_id.clone(), + per_source_namespace: material.diversity.per_source_namespace, + per_source_instance: material.diversity.per_source_instance, + per_repository: material.diversity.per_repository, + per_file: material.diversity.per_file, + per_session_or_thread: material.diversity.per_session_or_thread, + per_copy_cluster: material.diversity.per_copy_cluster, + per_evidence_role: material.diversity.per_evidence_role, + }, + rerank: None, + compatibility: RetrievalCompatibilityPinsV1 { + semantic: Some(SemanticCompatibilityPinsV1 { + implementation_revision: ComponentRevision::new("semantic.fastembed.production.v1") + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?, + fusion_revision: ComponentRevision::new( + tracedecay_query::retrieval::QUERY_RANKING_REVISION_V1, + ) + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?, + artifact_manifest_digest: embedding.model_artifact_digest.clone(), + runtime_compatibility_digest, + projection: vector.embedding_key().clone(), + search_index_key, + vector_generation_id, + calibration, + resources, + }), + rerank: None, + }, + }) +} + +pub(super) fn daemon_semantic_evaluation_resource_requirement( + configured_limits: crate::config::SemanticResourceCeilings, +) -> SemanticResourceRequirementV1 { + SemanticResourceRequirementV1 { + model_bytes: configured_limits.max_model_bytes, + tokenizer_bytes: configured_limits.max_tokenizer_bytes, + resident_bytes: configured_limits.max_resident_bytes, + threads: configured_limits.max_threads, + max_concurrent_sessions: configured_limits.max_concurrent_sessions, + batch_size: configured_limits.max_batch_size, + sequence_length: configured_limits.max_sequence_length, + load_deadline_ms: configured_limits.load_deadline_ms, + } +} + struct SemanticEvaluationWorkerV1 { control: Arc, handle: JoinHandle<()>, diff --git a/src/daemon/service/invocation/dispatch.rs b/src/daemon/service/invocation/dispatch.rs index f8bdf1224b..36d9dead14 100644 --- a/src/daemon/service/invocation/dispatch.rs +++ b/src/daemon/service/invocation/dispatch.rs @@ -800,7 +800,7 @@ impl DaemonInvocationService { .await } DaemonInvocationPayload::SemanticEvaluateAndPublish { - candidate, + evaluated_profile_id, observed_at, deadline, cancellation, @@ -808,7 +808,7 @@ impl DaemonInvocationService { self.execute_semantic_evaluation( project_root, request_id, - *candidate, + evaluated_profile_id, observed_at, deadline, cancellation, diff --git a/src/daemon/service/invocation/observability_producer.rs b/src/daemon/service/invocation/observability_producer.rs index 415573655e..6f03d6af91 100644 --- a/src/daemon/service/invocation/observability_producer.rs +++ b/src/daemon/service/invocation/observability_producer.rs @@ -158,23 +158,6 @@ impl DaemonInvocationService { .await } - /// The mounted producer together with the exact project session database - /// it writes through, for owners that also record directly through the - /// registered observation authority. - pub(crate) async fn observability_producer_with_database( - &self, - project_root: Option<&Path>, - ) -> Option<( - crate::global_db::RegisteredGlobalDbLeaseV1, - Arc, - )> { - self.project_runtimes - .read::(project_root?, |registered| { - (registered.database(), registered.producer()) - }) - .await - } - pub(crate) fn observability_producer_for_project_root( &self, project_root: &Path, diff --git a/src/daemon/service/invocation/semantic_evaluation.rs b/src/daemon/service/invocation/semantic_evaluation.rs index d7ed84a129..fa53af9343 100644 --- a/src/daemon/service/invocation/semantic_evaluation.rs +++ b/src/daemon/service/invocation/semantic_evaluation.rs @@ -1,10 +1,9 @@ use super::*; use tracedecay_runtime_core::cancellation::CancellationToken; -#[derive(Clone, Copy)] -enum SemanticExecutionIntentV1 { - Qualify, - EvaluateAndPublish, +enum SemanticExecutionInputV1 { + Qualify(Box), + EvaluateAndPublish(String), } enum SemanticExecutionOutcomeV1 { @@ -106,12 +105,11 @@ impl DaemonInvocationService { self.execute_semantic_operation( project_root, request_id, - candidate, observed_at, deadline, cancellation, request_cancellation, - SemanticExecutionIntentV1::Qualify, + SemanticExecutionInputV1::Qualify(Box::new(candidate)), ) .await } @@ -120,7 +118,7 @@ impl DaemonInvocationService { &self, project_root: Option<&Path>, request_id: String, - candidate: tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1, + evaluated_profile_id: String, observed_at: UtcMicros, deadline: Deadline, cancellation: CancellationContext, @@ -129,12 +127,11 @@ impl DaemonInvocationService { self.execute_semantic_operation( project_root, request_id, - candidate, observed_at, deadline, cancellation, request_cancellation, - SemanticExecutionIntentV1::EvaluateAndPublish, + SemanticExecutionInputV1::EvaluateAndPublish(evaluated_profile_id), ) .await } @@ -144,12 +141,11 @@ impl DaemonInvocationService { &self, project_root: Option<&Path>, request_id: String, - candidate: tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1, observed_at: UtcMicros, deadline: Deadline, cancellation: CancellationContext, request_cancellation: CancellationToken, - intent: SemanticExecutionIntentV1, + input: SemanticExecutionInputV1, ) -> DaemonInvocationResponse { let control = SemanticInvocationControlV1::new(observed_at, deadline, cancellation); if let Some(problem) = semantic_execution_interruption(&control, &request_cancellation) { @@ -171,9 +167,9 @@ impl DaemonInvocationService { DaemonInvocationProblem::Unavailable, ); }; - let operation = match intent { - SemanticExecutionIntentV1::Qualify => None, - SemanticExecutionIntentV1::EvaluateAndPublish => { + let operation = match &input { + SemanticExecutionInputV1::Qualify(_) => None, + SemanticExecutionInputV1::EvaluateAndPublish(_) => { let operation = registered.semantic_operation.get().cloned(); if let Some(problem) = semantic_execution_interruption(&control, &request_cancellation) @@ -215,8 +211,10 @@ impl DaemonInvocationService { let scope = registered.scope.clone(); let scheduler = self.code_index_schedulers.clone(); let workers = Arc::clone(®istered.semantic_evaluation_workers); - let execution = match intent { - SemanticExecutionIntentV1::Qualify => { + let configuration = registered.runtime.client(); + let execution = match input { + SemanticExecutionInputV1::Qualify(candidate) => { + let candidate = *candidate; workers .execute(worker_deadline, request_cancellation, move |control| { let authority = @@ -268,7 +266,7 @@ impl DaemonInvocationService { }) .await } - SemanticExecutionIntentV1::EvaluateAndPublish => { + SemanticExecutionInputV1::EvaluateAndPublish(evaluated_profile_id) => { let Some(operation) = operation else { return DaemonInvocationResponse::problem( request_id, @@ -277,16 +275,29 @@ impl DaemonInvocationService { }; workers .execute(worker_deadline, request_cancellation, move |control| { - let snapshot = - crate::daemon::semantic_evaluation::DaemonSemanticEvaluationSnapshotAuthorityV1::new( + async move { + let configured = control + .interruptible(configuration.current()) + .await? + .map_err(|_| SemanticActivationCoordinationErrorV1::Unavailable)?; + let candidate = crate::daemon::semantic_evaluation::build_daemon_semantic_evaluation_candidate( + &canonical_root, + &scope, + &scheduler, + &evaluated_profile_id, + configured.config.semantic.resources, + Arc::clone(&control), + ) + .await?; + let snapshot = + crate::daemon::semantic_evaluation::DaemonSemanticEvaluationSnapshotAuthorityV1::new( canonical_root.clone(), scope, scheduler, candidate.clone(), control, ); - let authority = crate::daemon::semantic_evaluation::DaemonSemanticEvaluationPublicationAuthorityV1::new(snapshot); - async move { + let authority = crate::daemon::semantic_evaluation::DaemonSemanticEvaluationPublicationAuthorityV1::new(snapshot); operation .evaluate_and_publish_profile(&authority, &canonical_root, candidate) .await @@ -438,8 +449,20 @@ fn semantic_evaluation_response( | SemanticActivationCoordinationErrorV1::RejectedDetail(_)), )) => application_problem(request_id, semantic_evaluation_rejection_problem(&error)), Err(DaemonSemanticEvaluationExecutionErrorV1::Coordination( - SemanticActivationCoordinationErrorV1::Conflict - | SemanticActivationCoordinationErrorV1::Runtime(_) + SemanticActivationCoordinationErrorV1::Conflict, + )) => application_problem( + request_id, + ApplicationProblem::Conflict { + diagnostic: SafeDiagnostic { + code: "semantic_evaluation.conflict".to_owned(), + message: "The semantic evaluation target changed before publication".to_owned(), + }, + retry: RetryDirective::AfterRevalidate, + legal_actions: vec![tracedecay_application::LegalAction::Refresh], + }, + ), + Err(DaemonSemanticEvaluationExecutionErrorV1::Coordination( + SemanticActivationCoordinationErrorV1::Runtime(_) | SemanticActivationCoordinationErrorV1::Unavailable, )) => DaemonInvocationResponse::problem(request_id, DaemonInvocationProblem::Unavailable), } @@ -550,4 +573,37 @@ mod tests { other => panic!("expected application problem, got {other:?}"), } } + + #[test] + fn indexing_cannot_be_published_as_an_empty_semantic_candidate() { + let state = tracedecay_usecases::semantic_runtime::SemanticRuntimeStateV1::Indexing { + completed_units: 7, + total_units: 11, + }; + + assert_eq!( + crate::daemon::semantic_evaluation::semantic_publication_generation(&state), + Err(SemanticActivationCoordinationErrorV1::Unavailable) + ); + } + + #[test] + fn stale_publication_conflict_remains_typed_across_the_daemon_boundary() { + let response = semantic_evaluation_response( + "req-semantic-conflict".to_owned(), + Err( + crate::daemon::semantic_evaluation::DaemonSemanticEvaluationExecutionErrorV1::Coordination( + SemanticActivationCoordinationErrorV1::Conflict, + ), + ), + ); + + match response.outcome { + DaemonInvocationOutcome::ApplicationProblem { problem } => { + assert_eq!(problem.kind(), ApplicationProblemKind::Conflict); + assert_eq!(problem.retry(), RetryDirective::AfterRevalidate); + } + other => panic!("expected typed conflict, got {other:?}"), + } + } } diff --git a/src/daemon/service/invocation/tests/project_admission_tests.rs b/src/daemon/service/invocation/tests/project_admission_tests.rs index 2f7ce635fc..8b26a76b8b 100644 --- a/src/daemon/service/invocation/tests/project_admission_tests.rs +++ b/src/daemon/service/invocation/tests/project_admission_tests.rs @@ -2,37 +2,6 @@ use super::*; -fn semantic_evaluation_candidate() --> tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1 { - let material = crate::search_eval::load_default_evaluated_profile_material("query-fallback") - .expect("checked-in query fallback profile"); - tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1 { - evaluated_profile_id: "query-fallback".to_owned(), - profile: tracedecay_usecases::semantic_runtime::SemanticEvaluationFusionCandidateV1 { - profile_id: material.profile.profile_id.clone(), - calibrations: material.profile.calibrations.clone(), - score_domain_calibrations: material.profile.score_domain_calibrations.clone(), - weights_micros: material.profile.weights_micros.clone(), - diversity_policy_id: material.profile.diversity_policy_id.clone(), - rerank_policy_id: material.profile.rerank_policy_id.clone(), - retrieval_budget: material.profile.retrieval_budget, - }, - diversity: tracedecay_usecases::semantic_runtime::SemanticEvaluationDiversityCandidateV1 { - policy_id: material.diversity.policy_id.clone(), - per_source_namespace: material.diversity.per_source_namespace, - per_source_instance: material.diversity.per_source_instance, - per_repository: material.diversity.per_repository, - per_file: material.diversity.per_file, - per_session_or_thread: material.diversity.per_session_or_thread, - per_copy_cluster: material.diversity.per_copy_cluster, - per_evidence_role: material.diversity.per_evidence_role, - }, - rerank: None, - compatibility: - tracedecay_usecases::config::retrieval::RetrievalCompatibilityPinsV1::default(), - } -} - #[test] fn retained_pre_reservation_admission_preserves_cancellation_and_timeout() { assert!(retained_request_admission_problem(RequestAdmission::Admitted).is_none()); @@ -73,7 +42,7 @@ async fn project_quiescence_denies_semantic_and_git_cached_routes() { let requests = [ DaemonInvocationRequest::semantic_evaluate_and_publish( "request.quiesced-semantic", - semantic_evaluation_candidate(), + "query-fallback".to_owned(), now, deadline.clone(), CancellationContext::active("cancel.quiesced-semantic").expect("cancellation"), diff --git a/src/daemon_client.rs b/src/daemon_client.rs index 12f0ce2555..ffa8fc22d3 100644 --- a/src/daemon_client.rs +++ b/src/daemon_client.rs @@ -592,10 +592,10 @@ impl DaemonInvocationClient { pub async fn evaluate_and_publish_semantic_profile( &self, - candidate: tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1, + evaluated_profile_id: &str, ) -> crate::errors::Result { self.evaluate_and_publish_semantic_profile_until( - candidate, + evaluated_profile_id, SEMANTIC_EVALUATION_DISPATCH_DEADLINE_MICROS, ) .await @@ -603,7 +603,7 @@ impl DaemonInvocationClient { pub async fn evaluate_and_publish_semantic_profile_until( &self, - candidate: tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1, + evaluated_profile_id: &str, deadline_micros: i64, ) -> crate::errors::Result { let request_id = @@ -637,7 +637,7 @@ impl DaemonInvocationClient { .invoke( crate::daemon_contract::DaemonInvocationRequest::semantic_evaluate_and_publish( request_id.as_str(), - candidate, + evaluated_profile_id.to_owned(), observed_at, deadline, cancellation, diff --git a/src/daemon_contract.rs b/src/daemon_contract.rs index ead70fb02d..cb703a3f99 100644 --- a/src/daemon_contract.rs +++ b/src/daemon_contract.rs @@ -888,7 +888,7 @@ pub(crate) enum DaemonInvocationPayload { cancellation: CancellationContext, }, SemanticEvaluateAndPublish { - candidate: Box, + evaluated_profile_id: String, observed_at: UtcMicros, deadline: Deadline, cancellation: CancellationContext, @@ -1482,7 +1482,7 @@ impl DaemonInvocationRequest { pub(crate) fn semantic_evaluate_and_publish( request_id: impl Into, - candidate: tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1, + evaluated_profile_id: String, observed_at: UtcMicros, deadline: Deadline, cancellation: CancellationContext, @@ -1493,7 +1493,7 @@ impl DaemonInvocationRequest { request_id: request_id.into(), delivery_route: None, payload: DaemonInvocationPayload::SemanticEvaluateAndPublish { - candidate: Box::new(candidate), + evaluated_profile_id, observed_at, deadline, cancellation, @@ -2218,12 +2218,22 @@ impl DaemonInvocationRequest { } } DaemonInvocationPayload::SemanticEvaluateAndPublish { - candidate, + evaluated_profile_id, observed_at, deadline, cancellation, + } => { + if evaluated_profile_id.trim() != evaluated_profile_id + || evaluated_profile_id.is_empty() + || evaluated_profile_id.len() > MAX_OPAQUE_HANDLE_BYTES + || observed_at.0 <= 0 + || deadline.expires_at.0 <= 0 + || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES + { + return Err(DaemonInvocationProblem::InvalidRequest); + } } - | DaemonInvocationPayload::SemanticQualify { + DaemonInvocationPayload::SemanticQualify { candidate, observed_at, deadline, @@ -2538,6 +2548,27 @@ mod semantic_qualification_tests { assert!(wire.get("snapshot_digest").is_none()); } + #[test] + fn semantic_publication_wire_carries_only_the_daemon_owned_profile_selection() { + let request = DaemonInvocationRequest::semantic_evaluate_and_publish( + "request.semantic-evaluation.default-profile", + "hybrid-conservative".to_owned(), + UtcMicros(1_000), + Deadline::new(UtcMicros(2_000)).expect("deadline"), + CancellationContext::active("cancellation.semantic-evaluation.default-profile") + .expect("cancellation"), + ); + + assert_eq!(request.validate(), Ok(())); + let wire = serde_json::to_value(request).expect("semantic evaluation wire"); + assert_eq!(wire["operation"], "semantic_evaluate_and_publish"); + assert_eq!(wire["evaluated_profile_id"], "hybrid-conservative"); + assert!( + wire.get("candidate").is_none(), + "caller-authored candidate material must not cross the publishing wire" + ); + } + #[test] fn semantic_qualification_outcome_carries_one_compact_canonical_blob() { let response = DaemonInvocationResponse::with_outcome( diff --git a/src/mcp/tools/definitions/git.rs b/src/mcp/tools/definitions/git.rs index 6e9d0ebeb0..656662a34c 100644 --- a/src/mcp/tools/definitions/git.rs +++ b/src/mcp/tools/definitions/git.rs @@ -104,11 +104,11 @@ pub(super) fn def_pr_context() -> ToolDefinition { "properties": { "base_ref": { "type": "string", - "description": "Base branch or ref to compare against (default: detected repository default branch)" + "description": "Base branch or ref to compare against (default: detected repository default branch). A short branch name selects the descendant of its local and origin tracking tips; use an explicit ref when they diverge." }, "head_ref": { "type": "string", - "description": "Head branch or ref (default: 'HEAD')" + "description": "Head branch or ref (default: 'HEAD'). Accepts local branches, remote-tracking refs such as origin/topic, full refs, and Git revision expressions." }, "maximum_symbols": { "type": "integer", diff --git a/src/mcp/tools/handlers/git/shell.rs b/src/mcp/tools/handlers/git/shell.rs index 96e03695ac..929bcc7476 100644 --- a/src/mcp/tools/handlers/git/shell.rs +++ b/src/mcp/tools/handlers/git/shell.rs @@ -5,6 +5,72 @@ use super::*; const PR_CONTEXT_MAX_ANCESTRY_COMMITS: usize = 100_000; const PR_CONTEXT_MAX_CHANGED_FILES: usize = 20_000; +fn resolve_pr_comparison_commit( + repo: &gix::Repository, + requested: &str, +) -> std::result::Result { + if requested == "HEAD" { + return repo + .rev_parse_single(requested) + .map_err(|error| format!("cannot resolve '{requested}': {error}"))? + .object() + .map_err(|error| format!("cannot read object for '{requested}': {error}"))? + .peel_to_commit() + .map(|commit| commit.id) + .map_err(|error| format!("cannot peel '{requested}' to commit: {error}")); + } + let local = exact_reference_commit(repo, &format!("refs/heads/{requested}"), requested)?; + let remote = + exact_reference_commit(repo, &format!("refs/remotes/origin/{requested}"), requested)?; + match (local, remote) { + (Some(local), Some(remote)) if local != remote => { + let merge_base = repo.merge_base(local, remote).map_err(|error| { + format!("cannot compare local and remote tips for '{requested}': {error}") + })?; + if merge_base == local { + Ok(remote) + } else if merge_base == remote { + Ok(local) + } else { + Err(format!( + "branch '{requested}' has diverged local and origin tips; pass 'refs/heads/{requested}' or 'origin/{requested}' explicitly" + )) + } + } + (Some(local), _) => Ok(local), + (_, Some(remote)) => Ok(remote), + (None, None) => repo + .rev_parse_single(requested) + .map_err(|error| format!("cannot resolve '{requested}': {error}"))? + .object() + .map_err(|error| format!("cannot read object for '{requested}': {error}"))? + .peel_to_commit() + .map(|commit| commit.id) + .map_err(|error| format!("cannot peel '{requested}' to commit: {error}")), + } +} + +fn exact_reference_commit( + repo: &gix::Repository, + full_name: &str, + requested: &str, +) -> std::result::Result, String> { + let Ok(full_name) = gix::refs::FullName::try_from(full_name) else { + return Ok(None); + }; + let reference = repo + .try_find_reference(&full_name) + .map_err(|error| format!("cannot inspect '{requested}': {error}"))?; + reference + .map(|mut reference| { + reference + .peel_to_commit() + .map(|commit| commit.id) + .map_err(|error| format!("cannot peel '{requested}' to commit: {error}")) + }) + .transpose() +} + /// Diff two git refs and return changed file paths with coarse status. pub(super) fn git_diff_file_changes( project_root: &std::path::Path, @@ -37,20 +103,12 @@ pub(super) fn git_pr_comparison_controlled( let repo = gix::open(project_root).map_err(|e| format!("failed to open git repo: {e}"))?; check_git_pr_cancelled(cancelled)?; let base_commit = repo - .rev_parse_single(base_ref) - .map_err(|e| format!("cannot resolve '{base_ref}': {e}"))? - .object() - .map_err(|e| format!("cannot read object for '{base_ref}': {e}"))? - .peel_to_commit() - .map_err(|e| format!("cannot peel '{base_ref}' to commit: {e}"))?; + .find_commit(resolve_pr_comparison_commit(&repo, base_ref)?) + .map_err(|error| format!("cannot read commit for '{base_ref}': {error}"))?; check_git_pr_cancelled(cancelled)?; let head_commit = repo - .rev_parse_single(head_ref) - .map_err(|e| format!("cannot resolve '{head_ref}': {e}"))? - .object() - .map_err(|e| format!("cannot read object for '{head_ref}': {e}"))? - .peel_to_commit() - .map_err(|e| format!("cannot peel '{head_ref}' to commit: {e}"))?; + .find_commit(resolve_pr_comparison_commit(&repo, head_ref)?) + .map_err(|error| format!("cannot read commit for '{head_ref}': {error}"))?; let base_oid = base_commit.id.to_string(); let head_oid = head_commit.id.to_string(); check_git_pr_cancelled(cancelled)?; @@ -485,6 +543,143 @@ mod tests { assert_eq!(comparison.commits[0]["subject"], "feature"); } + #[test] + fn pr_comparison_resolves_a_remote_only_branch_by_human_name() { + let temp = tempfile::tempdir().expect("temp repo"); + let root = temp.path(); + test_git(root, &["init", "-b", "main"]); + std::fs::write(root.join("base.txt"), "base\n").expect("write base"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "base"]); + test_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + test_git(root, &["switch", "--detach", "HEAD"]); + test_git(root, &["branch", "-D", "main"]); + test_git(root, &["switch", "-c", "feature"]); + std::fs::write(root.join("feature.txt"), "feature\n").expect("write feature"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "feature"]); + + let comparison = git_pr_comparison(root, "main", "feature") + .expect("a human branch name resolves its remote-tracking tip"); + let paths = comparison + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!(paths, ["feature.txt"]); + + let explicit = git_pr_comparison(root, "origin/main", "feature") + .expect("an explicit remote-tracking ref resolves without an object id"); + let explicit_paths = explicit + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!(explicit_paths, ["feature.txt"]); + } + + #[test] + fn pr_comparison_prefers_the_descendant_remote_tip_for_a_human_branch_name() { + let temp = tempfile::tempdir().expect("temp repo"); + let root = temp.path(); + test_git(root, &["init", "-b", "main"]); + std::fs::write(root.join("base.txt"), "base\n").expect("write base"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "base"]); + let local_main = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output() + .expect("read local main"); + assert!(local_main.status.success()); + let local_main = String::from_utf8(local_main.stdout) + .expect("UTF-8 oid") + .trim() + .to_owned(); + + std::fs::write(root.join("remote.txt"), "remote advance\n").expect("write remote file"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "remote advance"]); + test_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + test_git(root, &["reset", "--hard", &local_main]); + test_git( + root, + &["switch", "-c", "feature", "refs/remotes/origin/main"], + ); + std::fs::write(root.join("feature.txt"), "feature\n").expect("write feature"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "feature"]); + + let comparison = git_pr_comparison(root, "main", "feature") + .expect("a human branch name selects the newer remote-tracking tip"); + let paths = comparison + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!(paths, ["feature.txt"]); + } + + #[test] + fn pr_comparison_refuses_to_guess_between_diverged_local_and_remote_tips() { + let temp = tempfile::tempdir().expect("temp repo"); + let root = temp.path(); + test_git(root, &["init", "-b", "main"]); + std::fs::write(root.join("base.txt"), "base\n").expect("write base"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "base"]); + test_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + std::fs::write(root.join("local.txt"), "local\n").expect("write local file"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "local advance"]); + test_git(root, &["switch", "--detach", "refs/remotes/origin/main"]); + std::fs::write(root.join("remote.txt"), "remote\n").expect("write remote file"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "remote advance"]); + test_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + test_git(root, &["switch", "main"]); + + let Err(error) = git_pr_comparison(root, "main", "HEAD") else { + panic!("diverged human branch names must require an explicit ref"); + }; + assert!( + error.contains("has diverged local and origin tips"), + "typed ambiguity names both competing authorities: {error}" + ); + } + + #[test] + fn pr_comparison_default_head_never_aliases_the_remote_default_branch() { + let temp = tempfile::tempdir().expect("temp repo"); + let root = temp.path(); + test_git(root, &["init", "-b", "main"]); + std::fs::write(root.join("base.txt"), "base\n").expect("write base"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "base"]); + test_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + test_git( + root, + &[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + ], + ); + test_git(root, &["switch", "-c", "feature"]); + std::fs::write(root.join("feature.txt"), "feature\n").expect("write feature"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "feature"]); + + let comparison = git_pr_comparison(root, "main", "HEAD") + .expect("HEAD means the current checkout, never origin/HEAD"); + let paths = comparison + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!(paths, ["feature.txt"]); + } + #[test] fn pr_comparison_stops_from_inside_the_tree_diff_callback() { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/tests/daemon_suite/advanced_workflow_journey/task_session.rs b/tests/daemon_suite/advanced_workflow_journey/task_session.rs index 13287430e4..f1cf7964b8 100644 --- a/tests/daemon_suite/advanced_workflow_journey/task_session.rs +++ b/tests/daemon_suite/advanced_workflow_journey/task_session.rs @@ -18,31 +18,24 @@ use tracedecay_domain::configuration::{ SEMANTIC_RUNTIME_SETTING_KEY, SettingKey, }; use tracedecay_domain::{ - AdmittedEmbeddingProjectionKeyV1, CalibrationProfileId, ComponentRevision, ManifestDigest, - ProjectId, RetrieverKind, SemanticSearchIndexProfileV1, TaskId, TemporalModeV1, UtcMicros, - VectorGenerationIdV1, WorkAttemptIdentityV1, canonical_sha256, + ManifestDigest, ProjectId, RetrieverKind, TaskId, TemporalModeV1, UtcMicros, + VectorGenerationIdV1, WorkAttemptIdentityV1, }; use tracedecay_global_db::configuration::semantic::{SemanticConfig, SemanticProfileSelection}; -use tracedecay_query::retrieval::semantic::SemanticCalibrationProfileV1; use tracedecay_sdk::client::Client; use tracedecay_sdk::operations::{ ApplicationConfigurationGet, ApplicationConfigurationObservedState, ApplicationConfigurationSet, WorkRetrieveEvidence, }; use tracedecay_semantic::{ - DEFAULT_FASTEMBED_MODEL_ID, LoadedSemanticArtifactV1, SemanticModelLifecycleOwnerV1, - SemanticModelLifecycleStateV1, SemanticResourceCeilings, -}; -use tracedecay_usecases::config::retrieval::{ - RetrievalCompatibilityPinsV1, SemanticCompatibilityPinsV1, SemanticResourceRequirementV1, + DEFAULT_FASTEMBED_MODEL_ID, SemanticModelLifecycleOwnerV1, SemanticModelLifecycleStateV1, + SemanticResourceCeilings, }; use tracedecay_usecases::retention::code_index_generations::{ DurablePublicationPointerV1, scoped_code_index_store_root, }; use tracedecay_usecases::semantic_runtime::{ - SemanticEvaluationDiversityCandidateV1, SemanticEvaluationFusionCandidateV1, - SemanticEvaluationProfileCandidateV1, SemanticFallbackReasonV1, SemanticRuntimeStateV1, - SemanticRuntimeStatusV1, + SemanticFallbackReasonV1, SemanticRuntimeStateV1, SemanticRuntimeStatusV1, }; use super::{ @@ -397,22 +390,7 @@ fn activate_evaluated_semantic_profile( project_id: &ProjectId, installed: &InstalledSemanticFixture, ) -> ManifestDigest { - let (code, vector_generation) = wait_for_semantic_generation(home, project); - let lifecycle = SemanticModelLifecycleOwnerV1::open_default( - tracedecay_semantic::default_lifecycle_root_in(&home.join(".tracedecay")), - ) - .expect("reopen installed semantic lifecycle"); - let resources = journey_semantic_resources(); - let projection = - LoadedSemanticArtifactV1::lifecycle_projection(&lifecycle, code.manifest(), resources) - .expect("derive the installed model's admitted projection"); - let candidate = semantic_candidate(&code, &projection, vector_generation, resources); - let candidate_path = home.join("semantic-evaluation-candidate.json"); - std::fs::write( - &candidate_path, - serde_json::to_vec_pretty(&candidate).expect("semantic candidate JSON"), - ) - .expect("write semantic evaluation candidate"); + let _ = wait_for_semantic_generation(home, project); let mut evaluator = std::process::Command::new(env!("CARGO_BIN_EXE_tracedecay-search-eval-direct")); common::apply_tracedecay_home_env(&mut evaluator, home); @@ -421,8 +399,8 @@ fn activate_evaluated_semantic_profile( let output = evaluator .args(["evaluate-and-publish", "--project-root"]) .arg(project) - .arg("--candidate") - .arg(&candidate_path) + .arg("--profile") + .arg(EVALUATED_PROFILE_ID) .current_dir(project) .output() .expect("start direct semantic evaluator"); @@ -604,96 +582,6 @@ fn read_active_code_generation( .ok() } -fn semantic_candidate( - code: &tracedecay_code_index::production::CodeIndexPublishedGenerationV1, - projection: &AdmittedEmbeddingProjectionKeyV1, - vector_generation_id: VectorGenerationIdV1, - evaluation_limits: SemanticResourceCeilings, -) -> SemanticEvaluationProfileCandidateV1 { - let material = - tracedecay::search_eval::load_default_evaluated_profile_material(EVALUATED_PROFILE_ID) - .expect("checked-in evaluated profile material"); - let embedding = projection.embedding_key(); - let runtime_compatibility_digest = canonical_sha256(&( - "tracedecay.semantic-runtime-compatibility.v1", - &embedding.runtime_backend, - &embedding.runtime_build_revision, - embedding.device_class, - embedding.precision, - )) - .expect("runtime compatibility digest"); - let calibration = SemanticCalibrationProfileV1 { - calibration_profile_id: CalibrationProfileId::new( - "calibration.semantic.advanced-workflow-journey.v1", - ) - .expect("calibration profile id"), - cohort_digest: canonical_sha256(&( - "tracedecay.semantic.advanced-workflow-journey.cohort.v1", - code.manifest().generation_id.clone(), - vector_generation_id.clone(), - code.capability().manifest_digest.clone(), - )) - .expect("calibration cohort digest"), - projection_key: projection.projection_key().clone(), - vector_generation: vector_generation_id.clone(), - capability_manifest_digest: code.capability().manifest_digest.clone(), - maximum_distance_micros: i64::MAX, - minimum_margin_micros: 0, - }; - SemanticEvaluationProfileCandidateV1 { - evaluated_profile_id: EVALUATED_PROFILE_ID.to_owned(), - profile: SemanticEvaluationFusionCandidateV1 { - profile_id: material.profile.profile_id.clone(), - calibrations: material.profile.calibrations.clone(), - score_domain_calibrations: material.profile.score_domain_calibrations.clone(), - weights_micros: material.profile.weights_micros.clone(), - diversity_policy_id: material.profile.diversity_policy_id.clone(), - rerank_policy_id: material.profile.rerank_policy_id.clone(), - retrieval_budget: material.profile.retrieval_budget, - }, - diversity: SemanticEvaluationDiversityCandidateV1 { - policy_id: material.diversity.policy_id.clone(), - per_source_namespace: material.diversity.per_source_namespace, - per_source_instance: material.diversity.per_source_instance, - per_repository: material.diversity.per_repository, - per_file: material.diversity.per_file, - per_session_or_thread: material.diversity.per_session_or_thread, - per_copy_cluster: material.diversity.per_copy_cluster, - per_evidence_role: material.diversity.per_evidence_role, - }, - rerank: None, - compatibility: RetrievalCompatibilityPinsV1 { - semantic: Some(SemanticCompatibilityPinsV1 { - implementation_revision: ComponentRevision::new("semantic.fastembed.production.v1") - .expect("semantic implementation revision"), - fusion_revision: ComponentRevision::new( - "fusion.semantic.advanced-workflow-journey.v1", - ) - .expect("fusion revision"), - artifact_manifest_digest: embedding.model_artifact_digest.clone(), - runtime_compatibility_digest, - projection: projection.clone(), - search_index_key: SemanticSearchIndexProfileV1::exact_flat_v1() - .and_then(|profile| profile.index_key()) - .expect("production exact-flat semantic index"), - vector_generation_id, - calibration, - resources: SemanticResourceRequirementV1 { - model_bytes: evaluation_limits.max_model_bytes, - tokenizer_bytes: evaluation_limits.max_tokenizer_bytes, - resident_bytes: evaluation_limits.max_resident_bytes, - threads: evaluation_limits.max_threads, - max_concurrent_sessions: evaluation_limits.max_concurrent_sessions, - batch_size: evaluation_limits.max_batch_size, - sequence_length: evaluation_limits.max_sequence_length, - load_deadline_ms: evaluation_limits.load_deadline_ms, - }, - }), - rerank: None, - }, - } -} - /// Proves the evaluated profile is both selected through the public configuration /// authority and ready through the mounted runtime authority before TaskSession /// selection. TaskSession anchors deliberately retain only TaskSession provenance.