diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations.rs b/crates/tracedecay-code-index-retention/src/code_index_generations.rs index 96123504c5..9d62815f68 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -33,6 +33,10 @@ use tracedecay_domain::{ CodeGenerationId, ManifestDigest, UtcMicros, canonical_sha256, sha256_hex_suffix, }; +/// Temporary publication directories are protected by the shared project +/// segment lease. Only the exclusive segment sweep may reclaim abandoned ones. +pub const CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1: &str = ".code-index-preparation-"; + mod file_quarantine; #[cfg(any(test, feature = "test-helpers"))] pub mod fixture; @@ -1487,6 +1491,55 @@ fn sweep_unreferenced_generation_segments( let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { continue; }; + if file_name.starts_with(CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1) { + let metadata = path.symlink_metadata().map_err(deferred_if_absent)?; + if !metadata.file_type().is_dir() { + return Err(CodeGenerationRetentionErrorV1::UnsafeState(format!( + "generation preparation '{}' is not a regular directory", + path.display() + ))); + } + found = true; + if !apply { + return Ok((true, false)); + } + // The caller holds the exclusive project segment lock, which + // excludes every live preparation's shared lease. Runtime staging + // is flat; refusing other entry types avoids following any links. + let mut complete = true; + for staged in std::fs::read_dir(&path).map_err(storage)? { + if is_cancelled() { + return Err(CodeGenerationRetentionErrorV1::Cancelled); + } + charge_entry_budget(&mut remaining_entries)?; + let staged = staged.map_err(storage)?; + let staged_path = staged.path(); + let metadata = staged_path.symlink_metadata().map_err(deferred_if_absent)?; + if !metadata.file_type().is_file() { + return Err(CodeGenerationRetentionErrorV1::UnsafeState(format!( + "generation preparation entry '{}' is not a regular file", + staged_path.display() + ))); + } + std::fs::remove_file(&staged_path).map_err(storage)?; + reclaimed = reclaimed.saturating_add(metadata.len()); + reclaimed_segments += 1; + if reclaimed_segments == MAX_CODE_GENERATION_RETENTION_BATCH_V1 { + complete = false; + break; + } + } + if complete { + std::fs::remove_dir(&path).map_err(storage)?; + reclaimed_segments += 1; + } else { + sync_directory(&path)?; + } + if reclaimed_segments >= MAX_CODE_GENERATION_RETENTION_BATCH_V1 { + break; + } + continue; + } let Some(digest) = file_name .strip_prefix("segment-") .and_then(|name| name.strip_suffix(".json")) @@ -1517,12 +1570,12 @@ fn sweep_unreferenced_generation_segments( break; } } - if reclaimed > 0 { + if reclaimed > 0 || reclaimed_segments > 0 { sync_directory(&segments_root)?; } Ok(( found, - reclaimed_segments == MAX_CODE_GENERATION_RETENTION_BATCH_V1, + reclaimed_segments >= MAX_CODE_GENERATION_RETENTION_BATCH_V1, )) } diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 317e023186..757f7bde49 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -3790,3 +3790,212 @@ fn store_release_wait_outlasts_a_reader() { .is_some() ); } + +#[test] +fn abandoned_preparations_are_collected_only_after_shared_project_publication_leases_end() { + let project = tempfile::TempDir::new().expect("project store"); + let scope = project + .path() + .join(code_index_scope_hash(Path::new("/fixture/main"))); + let peer = project + .path() + .join(code_index_scope_hash(Path::new("/fixture/peer"))); + std::fs::create_dir_all(scope.join(GENERATIONS_DIRECTORY)).expect("main scope"); + std::fs::create_dir_all(peer.join(GENERATIONS_DIRECTORY)).expect("peer scope"); + let segments = code_generation_segments_root(&scope); + std::fs::create_dir_all(&segments).expect("segments root"); + // A process crash leaves the directory after its TempDir owner and shared + // lease disappear. Keep the fixture directory to exercise that filesystem + // state through the real planner and executor. + let abandoned = tempfile::Builder::new() + .prefix(CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1) + .tempdir_in(&segments) + .expect("publication staging") + .keep(); + std::fs::write( + abandoned.join("generation-evidence.tmp"), + b"interrupted publication", + ) + .expect("abandoned pack"); + let unrelated = segments.join("unrelated-directory"); + std::fs::create_dir(&unrelated).expect("unrelated directory"); + std::fs::write(unrelated.join("payload"), b"preserve").expect("unrelated payload"); + let lease = acquire_generation_segments_publication_lock(&peer, &|| false) + .expect("live peer publication lease"); + let plan = + plan_code_generation_retention(&scope, &BTreeSet::new()).expect("orphan-only census"); + assert!(plan.has_collectable_work()); + assert!(plan.collectable_generations.is_empty()); + let result = execute_code_generation_retention_cancellable( + &scope, + plan, + CodeGenerationRetentionModeV1::Apply, + UtcMicros(1), + None, + &|| false, + ); + assert!(matches!( + result, + Err(CodeGenerationRetentionErrorV1::GenerationStoreBusy) + )); + assert!(abandoned.join("generation-evidence.tmp").is_file()); + + let plan = + plan_code_generation_retention(&scope, &BTreeSet::new()).expect("protected orphan census"); + let prior_parks = locking::store_lock_park_count(); + let result = execute_code_generation_retention_cancellable( + &scope, + plan, + CodeGenerationRetentionModeV1::Apply, + UtcMicros(1), + None, + &|| locking::store_lock_park_count() > prior_parks, + ); + assert!(matches!( + result, + Err(CodeGenerationRetentionErrorV1::Cancelled) + )); + assert!( + locking::store_lock_park_count() > prior_parks, + "the canonical cancellation callback observes a contended lock wait" + ); + assert!(abandoned.join("generation-evidence.tmp").is_file()); + drop(lease); + + let plan = prepare_next_code_generation_retention_cancellable( + &scope, + &BTreeSet::new(), + &|| false, + None, + ) + .expect("ordinary maintenance finds stranded staging"); + assert!(plan.has_collectable_work()); + let report = execute_code_generation_retention( + &scope, + plan, + CodeGenerationRetentionModeV1::Apply, + UtcMicros(2), + None, + ) + .expect("reclaim abandoned staging"); + assert!(report.deleted_generations.is_empty()); + assert!(!abandoned.exists()); + assert_eq!( + std::fs::read(unrelated.join("payload")).expect("unrelated bytes"), + b"preserve" + ); +} + +#[test] +fn preparation_directory_collection_observes_cancellation_entry_and_batch_bounds() { + let store = tempfile::TempDir::new().expect("store"); + let segments = code_generation_segments_root(store.path()); + std::fs::create_dir_all(&segments).expect("segments root"); + let abandoned = tempfile::Builder::new() + .prefix(CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1) + .tempdir_in(&segments) + .expect("staging") + .keep(); + for index in 0..MAX_CODE_GENERATION_RETENTION_BATCH_V1 + 2 { + std::fs::write(abandoned.join(format!("segment-{index}.json")), b"").expect("staged file"); + } + let mut budget = 0; + assert!(matches!( + has_unreferenced_generation_segments_budgeted(store.path(), None, &|| false, &mut budget), + Err(CodeGenerationRetentionErrorV1::EntryBudgetExceeded) + )); + assert!(matches!( + has_unreferenced_generation_segments(store.path(), None, &|| true), + Err(CodeGenerationRetentionErrorV1::Cancelled) + )); + assert_eq!( + std::fs::read_dir(&abandoned).expect("staged files").count(), + MAX_CODE_GENERATION_RETENTION_BATCH_V1 + 2 + ); + let first = run_code_generation_retention( + store.path(), + &BTreeSet::new(), + CodeGenerationRetentionModeV1::Apply, + UtcMicros(1), + None, + ) + .expect("first bounded collection"); + assert!(first.generation_segment_batch_exhausted); + assert_eq!( + std::fs::read_dir(&abandoned) + .expect("remaining staging") + .count(), + 2 + ); + run_code_generation_retention( + store.path(), + &BTreeSet::new(), + CodeGenerationRetentionModeV1::Apply, + UtcMicros(2), + None, + ) + .expect("finish bounded collection"); + assert!(!abandoned.exists()); + let empty = tempfile::Builder::new() + .prefix(CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1) + .tempdir_in(&segments) + .expect("empty abandoned staging") + .keep(); + let plan = prepare_next_code_generation_retention_cancellable( + store.path(), + &BTreeSet::new(), + &|| false, + None, + ) + .expect("empty directory is collectable work"); + assert!(plan.has_collectable_work()); + execute_code_generation_retention( + store.path(), + plan, + CodeGenerationRetentionModeV1::Apply, + UtcMicros(3), + None, + ) + .expect("collect empty staging"); + assert!(!empty.exists()); +} + +#[cfg(unix)] +#[test] +fn preparation_collection_refuses_symbolic_links_without_following_them() { + let store = tempfile::TempDir::new().expect("store"); + let segments = code_generation_segments_root(store.path()); + std::fs::create_dir_all(&segments).expect("segments root"); + let outside = tempfile::TempDir::new().expect("outside staging authority"); + let payload = outside.path().join("payload"); + std::fs::write(&payload, b"preserve").expect("outside bytes"); + let link = segments.join(format!("{CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1}link")); + std::os::unix::fs::symlink(outside.path(), &link).expect("directory symlink"); + assert!(matches!( + plan_code_generation_retention(store.path(), &BTreeSet::new()), + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + )); + std::fs::remove_file(&link).expect("remove symbolic directory entry"); + let staging = tempfile::Builder::new() + .prefix(CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1) + .tempdir_in(&segments) + .expect("staging directory"); + std::os::unix::fs::symlink(&payload, staging.path().join("segment.json")) + .expect("file symlink"); + let plan = + plan_code_generation_retention(store.path(), &BTreeSet::new()).expect("stage census"); + assert!(matches!( + execute_code_generation_retention( + store.path(), + plan, + CodeGenerationRetentionModeV1::Apply, + UtcMicros(1), + None + ), + Err(CodeGenerationRetentionErrorV1::UnsafeState(_)) + )); + assert_eq!( + std::fs::read(&payload).expect("outside payload survives"), + b"preserve" + ); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/git_tree_capture.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/git_tree_capture.rs index e70172c700..7db65b9c88 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/git_tree_capture.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/git_tree_capture.rs @@ -656,6 +656,7 @@ impl CodeIndexWorktreeSchedulerV1 { retained_bytes: _retained_bytes, retained_reservations: _retained_reservations, } = captured; + drop(_retained_bytes); let requested_scope = CodeIndexGenerationScopeV1::for_snapshot(&snapshot); // Retained-history generations live inside the active publication // pointer, so they need an active generation to ride on. A store with @@ -698,6 +699,7 @@ impl CodeIndexWorktreeSchedulerV1 { let generation = owner .build_and_publish( CodeIndexBuildRequestV1 { + resident_memory: Arc::clone(&self.resident_memory), snapshot, captured_files, changed_files: changed_paths, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/graph_activation.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/graph_activation.rs index 90fc32338a..cf57c85ef8 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/graph_activation.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/graph_activation.rs @@ -633,7 +633,7 @@ impl CodeGraphActivationAuthorityV1 { ), )); } - native_runtime::spawn_blocking(move || latest.prewarm_serving_derivations()) + native_runtime::spawn_blocking(move || latest.prewarm_graph_derivations()) .await .map_err(|error| { CodeIndexSchedulerErrorV1::GraphActivation(format!( diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/ignored_dependencies.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/ignored_dependencies.rs index e64dbdf14a..7656613dd8 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/ignored_dependencies.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/ignored_dependencies.rs @@ -3,6 +3,7 @@ use std::collections::BTreeSet; use std::io::Read; use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; use gix::bstr::ByteSlice; use serde_json::Value; @@ -256,7 +257,8 @@ impl CodeIndexWorktreeSchedulerV1 { previous_roster: Vec, control: &dyn CodeIndexExecutionControlV1, ) -> Result { - let began_under = self.epoch.load(std::sync::atomic::Ordering::Acquire); + let source_control = self.reconcile_control(); + let began_under = source_control.expected_epoch(); let _workers = self.ensure_worker_plan()?; let _worker_memory = match self.reserve_worker_memory() { Ok(reservation) => reservation, @@ -305,8 +307,14 @@ impl CodeIndexWorktreeSchedulerV1 { return Err(error); } }; + drop(retained_bytes); + *self.owner.publication_mut() = self + .publication + .clone() + .with_reconcile_publication_fence(Arc::clone(&self.hints), source_control.clone()); let generation = match self.owner.build_and_publish( CodeIndexBuildRequestV1 { + resident_memory: Arc::clone(&self.resident_memory), snapshot: snapshot.clone(), captured_files, changed_files: changed_paths, @@ -320,12 +328,24 @@ impl CodeIndexWorktreeSchedulerV1 { ) { Ok(generation) => generation, Err(error) => { + let requirement = self.owner.take_reconstruction_requirement(); + // Refused entrypoint admission restores its prior roster; + // no unpublished generation may later resurrect that entry. + drop(self.owner.take_unpublished()); + let error = super::reconcile::discarded_construction_refusal( + error, + &source_control, + requirement, + false, + std::iter::once(&_worker_memory).chain(&retained_reservations), + self.resident_memory.admission_watermark_bytes(), + ); self.ignored_source_admissions = previous_roster; - return Err(error.into()); + return Err(error); } }; let generation_id = generation.manifest().generation_id.clone(); - drop((retained_bytes, retained_reservations)); + drop(retained_reservations); self.latest_content_identity = Some(generation.snapshot().content_identity.clone()); self.mark_reconciled( SourceContentManifestV1::for_snapshot(generation.snapshot()), diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/memory_tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/memory_tests.rs index 62139a246d..e88d206b8a 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/memory_tests.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/memory_tests.rs @@ -1,7 +1,7 @@ use std::fs; use std::num::NonZeroU64; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use tempfile::TempDir; use tracedecay_code_index::parallelism::CodeIndexWorkerRuntimeV1; @@ -11,9 +11,7 @@ use tracedecay_runtime_core::resident_memory::{ ResidentMemoryComponentIdV1, ResidentMemoryPressureV1, }; -use tracedecay_code_index::production::{ - CodeIndexProductionErrorV1, CodeIndexPublicationStoreErrorV1, -}; +use tracedecay_code_index::production::CodeIndexPublicationStoreErrorV1; use super::publication_store::ActiveGenerationDecodeChargeV1; use super::tests::OwnerSignals; @@ -65,14 +63,6 @@ fn worker_reservation_bytes(runtime: &CodeIndexWorkerRuntimeV1) -> u64 { )) } -fn expected_worker_reservation_on(runtime: &CodeIndexWorkerRuntimeV1, remaining_bytes: u64) -> u64 { - let planned_workers = usize::from(runtime.status().effective_workers); - let affordable = tracedecay_code_index::parallelism::memory_safe_worker_count(remaining_bytes); - tracedecay_code_index::parallelism::worker_reservation_bytes( - planned_workers.min(affordable).max(1), - ) -} - /// Ledger scenarios own their measured input independently of sibling tests. pub(super) fn ledger_memory(limit: NonZeroU64) -> Arc { Arc::new(ProcessResidentMemoryV1::with_pressure( @@ -283,6 +273,7 @@ fn fixture_scheduler_builds_on_the_fixture_width_not_the_host() { #[test] fn worker_memory_reservation_is_charged_and_released_by_raii() { + const MIB: u64 = 1024 * 1024; let project = fixture(); let project_id = ProjectId::new("project.code-index-worker-memory").expect("valid project"); let store = TempDir::new().expect("store root"); @@ -298,23 +289,37 @@ fn worker_memory_reservation_is_charged_and_released_by_raii() { 2, DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1.get(), ); - let authority = ledger_memory(DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1); + assert_eq!(runtime.status().effective_workers, 2); + let authority = ledger_memory(NonZeroU64::new(400 * MIB).expect("limit")); + let standing = authority + .reserve_process_shared( + ResidentMemoryComponentIdV1::new("test-standing-memory").unwrap(), + NonZeroU64::new(100 * MIB).unwrap(), + ) + .expect("standing charge fits"); scheduler.bind_resident_memory(Arc::clone(&authority)); - let reservation = scheduler - .reserve_worker_memory() - .expect("reserve worker memory"); + assert!( + tracedecay_code_index::parallelism::memory_safe_worker_count(300 * MIB).max(1) == 1, + "the old admission arithmetic would charge only one worker" + ); + let _entered = runtime.enter(); assert_eq!( - authority.snapshot().used_bytes, - expected_worker_reservation_on(&runtime, DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1.get()) + tracedecay_code_index::parallelism::install(rayon::current_num_threads) + .expect("installed executor width"), + 2 ); + let reservation = scheduler + .reserve_worker_memory() + .expect("both installed workers fit beside the standing charge"); + assert_eq!(reservation.reserved_bytes(), 2 * 128 * MIB); + assert_eq!(authority.snapshot().used_bytes, 100 * MIB + 2 * 128 * MIB); + assert_eq!(runtime.status().effective_workers, 2); drop(reservation); - assert_eq!(authority.snapshot().used_bytes, 0); + assert_eq!(authority.snapshot().used_bytes, standing.reserved_bytes()); } -/// A host-sized process-global plan must not spend the 6 GiB default -/// authority's typed snapshot headroom. The live failure was remount seating -/// gen 00000001, then refusing a 31-byte successor snapshot because -/// `reserve_worker_memory` had reserved `remaining / 128MiB` and used==limit. +/// Planning against the default authority leaves snapshot headroom while +/// admission charges the complete installed executor width. #[test] fn default_authority_worker_reserve_leaves_typed_snapshot_headroom() { let project = fixture(); @@ -327,9 +332,13 @@ fn default_authority_worker_reserve_leaves_typed_snapshot_headroom() { Arc::new(SharedCodeIndexBytePoolV1::default()), ) .expect("open scheduler"); - let runtime = bind_automatic_worker_runtime(&scheduler, 128, 128 * 1024 * 1024 * 1024); - // This case isolates the ledger's worker/snapshot split. Live process - // pressure can narrow the slab and is exercised with explicit samples below. + let runtime = bind_automatic_worker_runtime( + &scheduler, + 128, + DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1.get(), + ); + // This case isolates the installed worker/snapshot split. Measured process + // pressure is exercised with explicit samples below. let authority = ledger_memory(DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1); scheduler.bind_resident_memory(Arc::clone(&authority)); @@ -338,7 +347,7 @@ fn default_authority_worker_reserve_leaves_typed_snapshot_headroom() { .expect("6 GiB authority admits a memory-safe worker slab"); let used = authority.snapshot().used_bytes; let limit = DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1.get(); - assert_eq!(used, expected_worker_reservation_on(&runtime, limit)); + assert_eq!(used, worker_reservation_bytes(&runtime)); assert!( used < limit, "worker reserve must leave the typed non-worker headroom: used={used} limit={limit}" @@ -533,13 +542,11 @@ fn measured_rss_pressure_refuses_worker_admission_and_readmits_as_it_falls() { ); } -/// Live heap no resident owner charges still fills the process, so the ledger -/// claims room for the whole worker plan while measured RSS does not. A -/// refresh after the cold index sizes its worker slab to the measured headroom -/// and publishes, instead of asking for the full plan and being refused on -/// every retry. +/// Measured process headroom must fit the complete installed executor. +/// External memory release admits the same width without changing its pool. #[test] -fn a_refresh_after_cold_index_sizes_its_worker_slab_to_measured_headroom() { +fn a_refresh_with_installed_workers_waits_for_enough_measured_headroom() { + const GIB: u64 = 1024 * 1024 * 1024; let project = fixture(); let store = TempDir::new().expect("store root"); let mut scheduler = CodeIndexWorktreeSchedulerV1::open( @@ -554,17 +561,10 @@ fn a_refresh_after_cold_index_sizes_its_worker_slab_to_measured_headroom() { 8, DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1.get(), ); - let planned = u64::from(runtime.status().effective_workers); - assert!( - planned >= 2, - "the default plan must span more than one worker for the slab to narrow" - ); - // Measured headroom fits half the planned slab. The limit keeps the - // observed bytes under the high watermark and leaves the ledger room for - // the whole plan. - let headroom = - (planned / 2) * tracedecay_code_index::parallelism::INDEX_WORKER_RESIDENT_BUDGET_BYTES_V1; - let limit = NonZeroU64::new(8 * headroom).expect("limit"); + assert_eq!(runtime.status().effective_workers, 8); + let worker_bytes = worker_reservation_bytes(&runtime); + assert_eq!(worker_bytes, GIB); + let limit = NonZeroU64::new(4 * GIB).expect("limit"); let observed = Arc::new(AtomicU64::new(0)); let sampled = Arc::clone(&observed); let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler( @@ -588,8 +588,13 @@ fn a_refresh_after_cold_index_sizes_its_worker_slab_to_measured_headroom() { scheduler.reconcile_now().expect("cold index publishes"), CodeIndexReconcileOutcomeV1::Published(_) )); + let predecessor = scheduler.active_publication_generation_id_for_test(); + assert!(predecessor.is_some()); + let decodes_before = scheduler.sealed_decode_count(); + let used_before = authority.snapshot().used_bytes; - observed.store(limit.get() - headroom, Ordering::Release); + let refused_observed = 3 * GIB + GIB / 2; + observed.store(refused_observed, Ordering::Release); pressure.sample_and_publish(); fs::write( project.path().join("src/lib.rs"), @@ -597,16 +602,40 @@ fn a_refresh_after_cold_index_sizes_its_worker_slab_to_measured_headroom() { ) .expect("write source"); git(project.path(), &["commit", "-q", "-am", "refresh"]); - let refreshed = scheduler + let failure = scheduler .reconcile_now() - .expect("the refresh is admitted within measured headroom"); + .expect_err("the installed executor does not fit"); + assert_observed_worker_memory_refusal(&failure, refused_observed, limit.get()); + let CodeIndexSchedulerErrorV1::WorkerMemoryAdmission(admission) = &failure else { + panic!("expected installed-worker admission refusal"); + }; + assert_eq!(admission.requested_bytes(), worker_bytes); + assert_eq!( + scheduler.active_publication_generation_id_for_test(), + predecessor + ); + assert_eq!(scheduler.sealed_decode_count(), decodes_before); + assert_eq!(authority.snapshot().used_bytes, used_before); + + observed.store(2 * GIB + GIB / 2, Ordering::Release); + pressure.sample_and_publish(); assert!(matches!( - refreshed, + scheduler + .reconcile_now() + .expect("external release admits the installed executor"), CodeIndexReconcileOutcomeV1::Published(_) )); - let latest = scheduler.latest_complete().expect("refreshed generation"); + assert_eq!(runtime.status().effective_workers, 8); + assert_ne!( + scheduler.active_publication_generation_id_for_test(), + predecessor + ); + let latest = scheduler + .publication + .load_active_shared() + .expect("explicit decode admission") + .expect("refreshed generation"); let mut symbols = latest - .generation .symbols() .symbols .iter() @@ -617,18 +646,15 @@ fn a_refresh_after_cold_index_sizes_its_worker_slab_to_measured_headroom() { symbols, [ "src/lib.rs::refreshed_generation", - "src/lib.rs::retained_generation" + "src/lib.rs::retained_generation", ] ); } -/// A refresh decodes its active parent generation and builds under one worker -/// slab. Here another holder leaves the parent's measured decode plus 64 MiB -/// below the admission watermark: room for the parent, not for the parent -/// beside a 128 MiB worker. The refresh still publishes, with its slab -/// planned against what the resident parent leaves. +/// A graph-disabled retained-text refresh builds from sealed metadata without +/// materializing its parent or reserving the full executor's scratch again. #[test] -fn a_refresh_decodes_its_parent_before_planning_its_worker_slab() { +fn a_retained_text_refresh_does_not_decode_its_parent_under_memory_pressure() { const GIB: u64 = 1024 * 1024 * 1024; const MIB: u64 = 1024 * 1024; let project = fixture(); @@ -640,51 +666,45 @@ fn a_refresh_decodes_its_parent_before_planning_its_worker_slab() { Arc::new(SharedCodeIndexBytePoolV1::default()), ) .expect("open scheduler"); - bind_automatic_worker_runtime( + let runtime = bind_automatic_worker_runtime( &scheduler, 8, DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1.get(), ); let limit = NonZeroU64::new(4 * GIB).expect("limit"); - let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler( - limit, - Arc::new(|| { - Some(ProcessResidentSampleV1 { - resident_bytes: 0, - unreclaimable_bytes: 0, - swapped_bytes: 0, - cgroup_headroom_bytes: None, - }) - }), - )); - let high_watermark = pressure.high_watermark_bytes(); - let authority = Arc::new(ProcessResidentMemoryV1::with_pressure(limit, pressure)); + let authority = ledger_memory(limit); scheduler.bind_resident_memory(Arc::clone(&authority)); assert!(matches!( scheduler.reconcile_now().expect("cold index publishes"), CodeIndexReconcileOutcomeV1::Published(_) )); - scheduler + let metadata = scheduler .publication - .release_decoded_active_after_seal() - .expect("release the sealed decode"); - let ActiveGenerationDecodeChargeV1::Measured { - bytes: decode_bytes, - .. - } = scheduler - .publication - .active_generation_charge() - .expect("decode charge") - else { - panic!("the cold index measured the generation it published"); - }; - let holder_bytes = high_watermark - authority.snapshot().used_bytes - decode_bytes - 64 * MIB; - let _holder = authority + .load_active_manifest() + .expect("read active sealed metadata") + .expect("sealed predecessor") + .metadata; + let predecessor = scheduler.active_publication_generation_id_for_test(); + let decodes_before = scheduler.sealed_decode_count(); + assert_eq!( + decodes_before, 0, + "publishing sealed text does not decode its parent" + ); + assert_eq!( + scheduler + .publication + .active_generation_charge() + .expect("decode state"), + ActiveGenerationDecodeChargeV1::Unmeasured + ); + let holder = authority .reserve_process_shared( - ResidentMemoryComponentIdV1::new("test-text-build").expect("component"), - NonZeroU64::new(holder_bytes).expect("holder bytes"), + ResidentMemoryComponentIdV1::new("test-standing-memory").expect("component"), + NonZeroU64::new(limit.get() - authority.snapshot().used_bytes - 64 * MIB) + .expect("standing bytes"), ) - .expect("the holder fits the ledger"); + .expect("standing memory leaves the retained refresh room"); + assert!(worker_reservation_bytes(&runtime) > 64 * MIB); fs::write( project.path().join("src/lib.rs"), @@ -692,16 +712,29 @@ fn a_refresh_decodes_its_parent_before_planning_its_worker_slab() { ) .expect("write source"); git(project.path(), &["commit", "-q", "-am", "refresh"]); - let refreshed = scheduler - .reconcile_now() - .expect("the refresh decodes its parent and builds"); assert!(matches!( - refreshed, - CodeIndexReconcileOutcomeV1::Published(_) + scheduler + .reconcile_retained_text_generation_with(&metadata, true) + .expect("retained-text refresh builds within its real charge"), + Some(CodeIndexReconcileOutcomeV1::Published(_)) )); - let latest = scheduler.latest_complete().expect("refreshed generation"); + assert_ne!( + scheduler.active_publication_generation_id_for_test(), + predecessor + ); + assert_eq!( + scheduler.sealed_decode_count(), + decodes_before, + "the retained-text successor must not decode the predecessor" + ); + drop(holder); + let latest = scheduler + .publication + .load_active_shared() + .expect("explicit decode admission") + .expect("refreshed generation"); + assert_eq!(scheduler.sealed_decode_count(), decodes_before + 1); let mut symbols = latest - .generation .symbols() .symbols .iter() @@ -712,16 +745,13 @@ fn a_refresh_decodes_its_parent_before_planning_its_worker_slab() { symbols, [ "src/lib.rs::refreshed_generation", - "src/lib.rs::retained_generation" + "src/lib.rs::retained_generation", ] ); } -/// The seal hands the decoded generation back so the text build has the -/// memory; decoding it again must fit the process budget. Here another holder -/// keeps all but a sliver below the admission watermark, so the decode waits -/// (typed refusal, no decode, the ledger untouched) instead of materializing -/// the generation into the kill line, and runs once that holder lets go. +/// A consumer explicitly decodes and releases a generation. Its next decode +/// uses that measured cost and must wait until the full cost fits again. #[test] fn a_released_generation_decodes_again_only_once_its_bytes_fit_the_budget() { const GIB: u64 = 1024 * 1024 * 1024; @@ -735,43 +765,44 @@ fn a_released_generation_decodes_again_only_once_its_bytes_fit_the_budget() { ) .expect("open scheduler"); let limit = NonZeroU64::new(16 * GIB).expect("limit"); - // This tests ledger admission independently of unrelated process allocations. - let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler( - limit, - Arc::new(|| { - Some(ProcessResidentSampleV1 { - resident_bytes: 0, - unreclaimable_bytes: 0, - swapped_bytes: 0, - cgroup_headroom_bytes: None, - }) - }), - )); - let high_watermark = pressure.high_watermark_bytes(); - let authority = Arc::new(ProcessResidentMemoryV1::with_pressure(limit, pressure)); + let authority = ledger_memory(limit); + let high_watermark = authority.admission_watermark_bytes(); scheduler.bind_resident_memory(Arc::clone(&authority)); assert!(matches!( scheduler.reconcile_now().expect("publish generation"), CodeIndexReconcileOutcomeV1::Published(_) )); + let measured = scheduler + .publication + .load_active_shared() + .expect("first explicit decode admission") + .expect("published generation"); + drop(measured); scheduler .publication .release_decoded_active_after_seal() - .expect("release the sealed decode"); + .expect("release the explicitly decoded generation"); + let ActiveGenerationDecodeChargeV1::Measured { + bytes: decode_bytes, + .. + } = scheduler + .publication + .active_generation_charge() + .expect("measured decode charge") + else { + panic!("an explicit decode must measure the next decode's charge"); + }; + assert!(decode_bytes > 0); let decodes_before = scheduler.sealed_decode_count(); - + let used_before = authority.snapshot().used_bytes; let holder = authority .reserve_process_shared( - ResidentMemoryComponentIdV1::new("test-text-build").expect("component"), - NonZeroU64::new(high_watermark - 1024).expect("holder bytes"), + ResidentMemoryComponentIdV1::new("test-standing-memory").expect("component"), + NonZeroU64::new(high_watermark - used_before - (decode_bytes - 1)) + .expect("holder bytes"), ) - .expect("the holder fits the ledger"); + .expect("standing memory leaves one byte less than the measured decode needs"); let used_while_held = authority.snapshot().used_bytes; - - assert!( - scheduler.latest_complete().is_none(), - "a decode that does not fit must not materialize the generation" - ); assert!(matches!( scheduler.publication.load_active_shared(), Err(CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused(_)) @@ -785,12 +816,13 @@ fn a_released_generation_decodes_again_only_once_its_bytes_fit_the_budget() { drop(holder); let decoded = scheduler - .latest_complete() - .expect("the decode runs once the memory is given back"); + .publication + .load_active_shared() + .expect("decode admission after external release") + .expect("published generation"); assert_eq!(scheduler.sealed_decode_count(), decodes_before + 1); assert_eq!( decoded - .generation .symbols() .symbols .iter() @@ -798,10 +830,17 @@ fn a_released_generation_decodes_again_only_once_its_bytes_fit_the_budget() { .collect::>(), ["src/lib.rs::retained_generation"] ); + let repeated = scheduler + .publication + .load_active_shared() + .expect("repeat explicit decode admission") + .expect("published generation"); + assert!(Arc::ptr_eq(&decoded, &repeated)); + assert_eq!(scheduler.sealed_decode_count(), decodes_before + 1); assert_eq!( authority.snapshot().used_bytes, - 0, - "the decode's charge is released once it completes" + used_before, + "the transient decode reservation releases after the decode completes" ); } @@ -890,11 +929,8 @@ fn a_decode_is_admitted_against_unreclaimable_bytes_not_clean_file_pages() { ); } -/// A linked-worktree reconcile is admitted while RSS is still under the -/// watermark and then keeps allocating. The next checkpoint has to read the -/// process again: once the sample crosses the watermark the pass stops as a -/// capacity refusal and the worktree stays unpublished. Treating that stop as -/// a superseded epoch would start another capture immediately. +/// Live pressure crosses the watermark after real construction starts. The +/// refusal retains the full witnessed reconstruction demand through unwind. #[test] fn reconcile_stops_when_resident_memory_crosses_the_watermark() { let project = fixture(); @@ -906,44 +942,64 @@ fn reconcile_stops_when_resident_memory_crosses_the_watermark() { Arc::new(SharedCodeIndexBytePoolV1::default()), ) .expect("open scheduler"); + let runtime = bind_automatic_worker_runtime( + &scheduler, + 2, + DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1.get(), + ); let limit = NonZeroU64::new(16 * 1024 * 1024 * 1024).expect("limit"); - let sampled = Arc::new(AtomicU64::new(0)); - let ceiling = limit.get(); - let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler( - limit, + let ledger = Arc::new(OnceLock::>::new()); + let external_burst = Arc::new(AtomicBool::new(false)); + let construction_peak = Arc::new(AtomicU64::new(0)); + let construction = ResidentMemoryComponentIdV1::new("code_index.construction.v1").unwrap(); + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler(limit, { + let ledger = Arc::clone(&ledger); + let external_burst = Arc::clone(&external_burst); + let construction_peak = Arc::clone(&construction_peak); Arc::new(move || { - let read = sampled.fetch_add(1, Ordering::AcqRel); - let unreclaimable_bytes = if read == 0 { 1 } else { ceiling }; + if let Some(memory) = ledger.get().and_then(Weak::upgrade) { + let bytes = memory.snapshot().process_shared_charge_for(construction); + construction_peak.fetch_max(bytes, Ordering::AcqRel); + if bytes > 0 { + external_burst.store(true, Ordering::Release); + } + } + // The external occupancy persists after the build releases itself. + let bytes = if external_burst.load(Ordering::Acquire) { + limit.get() + } else { + 1 + }; Some(ProcessResidentSampleV1 { - resident_bytes: unreclaimable_bytes, - unreclaimable_bytes, + resident_bytes: bytes, + unreclaimable_bytes: bytes, swapped_bytes: 0, cgroup_headroom_bytes: None, }) - }), - )); - scheduler.bind_resident_memory(Arc::new(ProcessResidentMemoryV1::with_pressure( - limit, pressure, - ))); - + }) + })); + let authority = Arc::new(ProcessResidentMemoryV1::with_pressure(limit, pressure)); + ledger.set(Arc::downgrade(&authority)).unwrap(); + scheduler.bind_resident_memory(Arc::clone(&authority)); let error = scheduler .reconcile_now() - .expect_err("a reconcile whose live RSS crosses the watermark must not publish"); - assert!( - error.is_transient_capacity_failure(), - "the watermark stop must stay retryable after RSS falls: {error}" - ); - assert!( - matches!( - error, - CodeIndexSchedulerErrorV1::Production(CodeIndexProductionErrorV1::Publication( - CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused(_) - )) - ), - "the stop must be a resident-memory refusal, not a superseded retry: {error}" + .expect_err("a reconcile whose live pressure crosses the watermark must not publish"); + assert!(external_burst.load(Ordering::Acquire)); + assert!(construction_peak.load(Ordering::Acquire) > 0); + assert!(error.is_transient_capacity_failure()); + assert!(error.is_resident_memory_refusal()); + let CodeIndexSchedulerErrorV1::ReconstructionMemoryRefused { demand, .. } = &error else { + panic!("expected a witnessed reconstruction refusal, got {error:?}"); + }; + assert!(demand.required_bytes() >= u128::from(worker_reservation_bytes(&runtime))); + assert!(!demand.can_retry()); + assert_eq!( + authority.snapshot().used_bytes, + 0, + "discarded build charges unwind" ); assert!( matches!(scheduler.publication.load_active_shared(), Ok(None)), - "the refused reconcile must leave the worktree unpublished" + "the refused reconcile leaves the worktree unpublished" ); } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 31995c7d46..e5b8dff061 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -20,8 +20,9 @@ use same_file::Handle; use sha2::{Digest, Sha256}; use tracedecay_application::code_index::DaemonCodeIndexControlV1; use tracedecay_code_index_retention::code_index_generations::{ - CodeGenerationRetentionErrorV1, CodeGenerationStoreLockV1, CodeIndexScopeStoreResetV1, - DurableGenerationCardinalityV1, DurableGenerationIndexEntryV1, DurablePublicationPointerV1, + CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1, CodeGenerationRetentionErrorV1, + CodeGenerationStoreLockV1, CodeIndexScopeStoreResetV1, DurableGenerationCardinalityV1, + DurableGenerationIndexEntryV1, DurablePublicationPointerV1, DurableSealedCodeGenerationIdentityV1, MAX_DURABLE_GENERATION_INDEX_BYTES_V1, MAX_DURABLE_GENERATION_INDEX_ENTRIES_V1, acquire_generation_segments_publication_lock, code_generation_segments_root, durable_generation_index_digest, reset_code_index_scope_store, @@ -44,9 +45,9 @@ use tracedecay_code_index::{ chunks::content_digest, production::{ CodeGraphBuildBoundV1, CodeIndexAtomicPublicationPort, CodeIndexExecutionControlV1, - CodeIndexGenerationScopeV1, CodeIndexInterruptionV1, CodeIndexProductionErrorV1, - CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, - CodeIndexSealedGenerationV1, CodeIndexSealedPublicationV1, + CodeIndexGenerationScopeV1, CodeIndexInterruptionV1, CodeIndexPreparationPort, + CodeIndexProductionErrorV1, CodeIndexPublicationStoreErrorV1, + CodeIndexPublishedGenerationV1, CodeIndexSealedGenerationV1, CodeIndexSealedPublicationV1, SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, SealedSegmentReaderV1, SharedDecodedContentPoolV1, SharedPhysicalCodeArtifactPoolV1, VerifiedSealedTextGenerationMetadataV1, @@ -489,9 +490,6 @@ pub struct DaemonCodeIndexPublicationStoreV1 { active_path: PathBuf, pub(super) generations_root: PathBuf, segments_root: PathBuf, - /// Names this store's temporaries in a segment directory other worktree - /// scopes of the project publish into as well. - segment_temporary_prefix: String, pub(super) project_root: PathBuf, expected_sanitizer_revision: SanitizerRevision, disposition: CodeIndexPublicationDispositionV1, @@ -512,10 +510,8 @@ pub struct DaemonCodeIndexPublicationStoreV1 { /// of how many new file segments it wrote. #[cfg(test)] segments_dir_sync_observer: Option>, - /// Last generation handed to `publish_atomically`. A transient store - /// failure must not drop it: the next undecoded retry republishes this - /// candidate instead of extracting the whole worktree again. - pub(super) unpublished_candidate: Arc>>, + /// Retry ownership includes the staged files as well as the verified manifest. + pub(super) unpublished_candidate: Arc>>, /// The active pointer's authenticated manifest, by its state digest, so /// builds and status over one publication read and parse it once. active_manifest_memo: Arc>>, @@ -536,9 +532,283 @@ enum CodeIndexPublicationDispositionV1 { RetainedHistory, } -struct TemporaryGenerationFileV1 { - path: PathBuf, - committed: bool, +/// A retry owns both staged bytes and the exact publication that names them. +pub(super) struct UnpublishedCodeIndexPublicationV1 { + pub(super) preparation: DaemonCodeIndexPreparationV1, + pub(super) publication: CodeIndexSealedPublicationV1, +} + +/// An unpublished store transaction. No full store handle is retained here: +/// the current handle supplies the source fence when commit is retried. +pub struct DaemonCodeIndexPreparationV1 { + scope: CodeIndexGenerationScopeV1, + expected_active_generation: Option, + undecoded_expectation: Option, + incumbent_bytes: Option>, + store_identity: Handle, + segments_identity: Handle, + generations_identity: Handle, + cache: Arc, + active_path: PathBuf, + segments_root: PathBuf, + disposition: CodeIndexPublicationDispositionV1, + state: Arc>, + shutdown_signal: Option>, + capture_control: Option, + encoded_segment_bytes: Arc, + existing_segment_bytes_read: Arc, + evidence_page_count: Arc, + evidence_durable_transaction_count: Arc, + #[cfg(test)] + segment_observer: Option>, +} + +enum DaemonCodeIndexPreparationStateV1 { + Open(Box), + Committed, + Aborted, +} + +struct DaemonCodeIndexPreparationFilesV1 { + // File owners drop before the directory, including on platforms that + // prohibit removing an open file. Retention outlives all staging cleanup. + segments: StagedGenerationSegmentsV1, + evidence: TemporaryEvidencePackV1, + directory: tempfile::TempDir, + _retention: CodeGenerationStoreLockV1, + referenced_segment_bytes: u64, + manifest_identity: Option, + pending_pointer: Option>, + segments_durable: bool, +} + +impl DaemonCodeIndexPreparationV1 { + fn closed() -> CodeIndexPublicationStoreErrorV1 { + DaemonCodeIndexPublicationStoreV1::unavailable("code-index preparation is closed") + } + + fn cache_matches( + &self, + state: &DecodedGenerationStateV1, + installed_generation: Option<&CodeGenerationId>, + ) -> bool { + state.active.as_ref().is_none_or(|current| { + let cached = ¤t.manifest().generation_id; + installed_generation == Some(cached) + || self.undecoded_expectation.as_ref().map_or( + Some(cached) == self.expected_active_generation.as_ref(), + |expected| cached.as_str() == expected.generation_id.as_str(), + ) + }) + } + + fn invalidate_uncommitted(&self) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if matches!(&*state, DaemonCodeIndexPreparationStateV1::Open(_)) { + // Escaped readers keep only an invalidated cell. Standard tempfile + // ownership removes staging while this drops the retention lease. + *state = DaemonCodeIndexPreparationStateV1::Aborted; + } + } + + fn abort_open(&self) -> Result<(), CodeIndexPublicationStoreErrorV1> { + let files = { + let mut state = self.state.lock().map_err(|_| Self::closed())?; + if !matches!(&*state, DaemonCodeIndexPreparationStateV1::Open(_)) { + return Ok(()); + } + match std::mem::replace(&mut *state, DaemonCodeIndexPreparationStateV1::Aborted) { + DaemonCodeIndexPreparationStateV1::Open(files) => files, + _ => return Err(Self::closed()), + } + }; + let DaemonCodeIndexPreparationFilesV1 { + directory, + segments, + mut evidence, + _retention, + .. + } = *files; + evidence.rollback_unattached(directory.path())?; + drop(evidence); + drop(segments); + let result = directory + .close() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable); + drop(_retention); + result + } +} + +impl Drop for DaemonCodeIndexPreparationV1 { + fn drop(&mut self) { + self.invalidate_uncommitted(); + } +} + +impl CodeIndexPreparationPort for DaemonCodeIndexPreparationV1 { + fn write_segment( + &mut self, + publication: SealedGenerationSegmentPublicationV1<'_>, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + if let Err(error) = DaemonCodeIndexPublicationStoreV1::publication_checkpoint( + self.shutdown_signal.as_deref(), + self.capture_control.as_ref(), + ) { + if matches!(&error, CodeIndexPublicationStoreErrorV1::CompareAndSwap) { + self.invalidate_uncommitted(); + } + return Err(error); + } + let mut state = self.state.lock().map_err(|_| Self::closed())?; + let DaemonCodeIndexPreparationStateV1::Open(files) = &mut *state else { + return Err(Self::closed()); + }; + if files.manifest_identity.is_some() { + return Err(DaemonCodeIndexPublicationStoreV1::unavailable( + "code-index preparation already entered commit", + )); + } + let (digest, bytes, is_file, durable_span) = match publication { + SealedGenerationSegmentPublicationV1::File { digest, bytes } => ( + digest, + bytes, + true, + tracing::trace_span!("code_index.generation.publish.segment_durable"), + ), + SealedGenerationSegmentPublicationV1::FileEvidence { digest, bytes } + | SealedGenerationSegmentPublicationV1::ResolutionIndex { digest, bytes } => ( + digest, + bytes, + false, + tracing::trace_span!("code_index.generation.publish.file_evidence_durable"), + ), + SealedGenerationSegmentPublicationV1::CodeGraphPage { + page_digest, bytes, .. + } => ( + page_digest, + bytes, + false, + tracing::trace_span!("code_index.generation.publish.graph_page_durable"), + ), + SealedGenerationSegmentPublicationV1::GenerationEvidencePage { + page_ordinal, + page_digest, + bytes, + } => { + let _span = + tracing::trace_span!("code_index.generation.publish.evidence_page_append") + .entered(); + files + .evidence + .append_page(page_ordinal, page_digest, bytes)?; + self.evidence_page_count.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } + SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { + segment_digest, + segment_size_bytes, + page_count, + } => { + let _span = + tracing::trace_span!("code_index.generation.publish.evidence_commit").entered(); + // The existing pack authority validates every page and the + // complete digest, but names it only inside this session. + if files.evidence.commit( + &self.segments_root, + files.directory.path(), + segment_digest, + segment_size_bytes, + page_count, + )? { + self.evidence_durable_transaction_count + .fetch_add(1, Ordering::Relaxed); + let suffix = sha256_hex_suffix(segment_digest.as_str()).ok_or_else(|| { + DaemonCodeIndexPublicationStoreV1::unavailable( + "sealed evidence pack digest is not sha256", + ) + })?; + let name = format!("segment-{suffix}.json"); + let path = files.directory.path().join(&name); + // commit already synced the complete pack. Other segment + // files use the shared batch flush before canonical naming. + files + .segments + .pending + .insert(self.segments_root.join(name), path); + files.evidence.attach_to_manifest(); + } + files.referenced_segment_bytes = files + .referenced_segment_bytes + .saturating_add(segment_size_bytes); + return Ok(()); + } + }; + let segment_size = + u64::try_from(bytes.len()).map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + { + let _span = durable_span.entered(); + DaemonCodeIndexPublicationStoreV1::stage_segment( + &self.segments_root, + files.directory.path(), + &self.existing_segment_bytes_read, + digest, + bytes, + &mut files.segments, + )?; + } + if is_file { + self.encoded_segment_bytes + .fetch_add(segment_size, Ordering::Relaxed); + #[cfg(test)] + if let Some(observer) = self.segment_observer.as_ref() { + observer(); + } + } + files.referenced_segment_bytes = + files.referenced_segment_bytes.saturating_add(segment_size); + Ok(()) + } + + fn segment_reader(&self) -> Arc { + let state = Arc::clone(&self.state); + let segments_root = self.segments_root.clone(); + Arc::new(move |request, buffer| { + let state = state + .lock() + .map_err(|_| CodeIndexProductionErrorV1::Publication(Self::closed()))?; + match &*state { + DaemonCodeIndexPreparationStateV1::Aborted => { + Err(CodeIndexProductionErrorV1::Publication(Self::closed())) + } + DaemonCodeIndexPreparationStateV1::Committed => { + DaemonCodeIndexPublicationStoreV1::read_segment_under( + &segments_root, + request, + buffer, + ) + } + DaemonCodeIndexPreparationStateV1::Open(files) => { + let digest = match request { + SealedGenerationSegmentReadV1::Whole { digest, .. } + | SealedGenerationSegmentReadV1::Range { digest, .. } => digest, + }; + let suffix = sha256_hex_suffix(digest.as_str()).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed segment digest is not sha256".to_owned(), + ) + })?; + let canonical = segments_root.join(format!("segment-{suffix}.json")); + let path = files.segments.pending.get(&canonical).unwrap_or(&canonical); + DaemonCodeIndexPublicationStoreV1::read_segment_at(path, request, buffer) + } + } + }) + } + + fn abort(self) -> Result<(), CodeIndexPublicationStoreErrorV1> { + self.abort_open() + } } pub(super) struct TemporaryEvidencePackV1 { @@ -558,6 +828,7 @@ struct StagedGenerationSegmentsV1 { durable: DurableFileBatch, /// Temporary path of every written segment, keyed by its final name. pending: BTreeMap, + flushed: bool, } impl StagedGenerationSegmentsV1 { @@ -565,6 +836,7 @@ impl StagedGenerationSegmentsV1 { Self { durable: DurableFileBatch::new(), pending: BTreeMap::new(), + flushed: false, } } @@ -572,23 +844,41 @@ impl StagedGenerationSegmentsV1 { self.pending.contains_key(final_path) } - /// Flush every staged segment once, then name each. Returns whether a - /// rename happened, which the caller makes durable with one directory - /// fsync. - fn publish(&mut self) -> Result { - if self.pending.is_empty() { - return Ok(false); + /// Flush every staged segment once, then name each. A retry resumes the + /// remaining names without trying to sync paths already renamed. + fn publish(&mut self) -> Result<(), CodeIndexPublicationStoreErrorV1> { + if !self.flushed { + self.durable + .sync() + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + self.flushed = true; } - self.durable - .sync() - .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; - while let Some((final_path, temporary)) = self.pending.pop_first() { - if let Err(error) = std::fs::rename(&temporary, &final_path) { - self.pending.insert(final_path, temporary); - return Err(DaemonCodeIndexPublicationStoreV1::unavailable(error)); + while let Some((final_path, temporary)) = self + .pending + .first_key_value() + .map(|(final_path, temporary)| (final_path.clone(), temporary.clone())) + { + match final_path.symlink_metadata() { + Ok(metadata) => { + if !metadata.file_type().is_file() + || !DaemonCodeIndexPublicationStoreV1::files_equal(&final_path, &temporary)? + { + return Err(DaemonCodeIndexPublicationStoreV1::corruption( + "existing sealed segment does not match its content address", + )); + } + std::fs::remove_file(&temporary) + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::rename(&temporary, &final_path) + .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; + } + Err(error) => return Err(DaemonCodeIndexPublicationStoreV1::unavailable(error)), } + self.pending.remove(&final_path); } - Ok(true) + Ok(()) } } @@ -672,6 +962,7 @@ impl TemporaryEvidencePackV1 { fn commit( &mut self, root: &Path, + staging_root: &Path, segment_digest: &ManifestDigest, segment_size_bytes: u64, page_count: u32, @@ -724,10 +1015,11 @@ impl TemporaryEvidencePackV1 { .sync_all() .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; drop(self.file.take()); - std::fs::rename(&self.path, &final_path) + let staged_path = staging_root.join(format!("segment-{digest_hex}.json")); + std::fs::rename(&self.path, &staged_path) .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; - self.published_path = Some(final_path); - DaemonCodeIndexPublicationStoreV1::sync_directory(root)?; + self.published_path = Some(staged_path); + DaemonCodeIndexPublicationStoreV1::sync_directory(staging_root)?; Ok(true) } @@ -778,52 +1070,6 @@ impl Drop for TemporaryEvidencePackV1 { } } -impl TemporaryGenerationFileV1 { - fn new(path: PathBuf) -> Self { - Self { - path, - committed: false, - } - } - - fn commit(&mut self) { - self.committed = true; - } - - fn rollback_uncommitted( - &mut self, - root: &Path, - ) -> Result<(), CodeIndexPublicationStoreErrorV1> { - if self.committed { - return Ok(()); - } - match self.path.symlink_metadata() { - Ok(metadata) if metadata.file_type().is_file() => { - std::fs::remove_file(&self.path) - .map_err(DaemonCodeIndexPublicationStoreV1::unavailable)?; - DaemonCodeIndexPublicationStoreV1::sync_directory(root)?; - } - Ok(_) => { - return Err(DaemonCodeIndexPublicationStoreV1::unavailable( - "sealed code-generation temporary path is not a regular file", - )); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(DaemonCodeIndexPublicationStoreV1::unavailable(error)), - } - self.committed = true; - Ok(()) - } -} - -impl Drop for TemporaryGenerationFileV1 { - fn drop(&mut self) { - if !self.committed { - let _ = std::fs::remove_file(&self.path); - } - } -} - impl DaemonCodeIndexPublicationStoreV1 { pub(super) fn new( store_root: &Path, @@ -834,16 +1080,6 @@ impl DaemonCodeIndexPublicationStoreV1 { std::fs::create_dir_all(&generations_root)?; let segments_root = code_generation_segments_root(store_root); std::fs::create_dir_all(&segments_root)?; - let segment_temporary_prefix = format!( - "{}.{}", - store_root - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| std::io::Error::other( - "code-generation store root has no UTF-8 name" - ))?, - std::process::id() - ); let _store_lock = try_acquire_code_generation_store_lock(store_root) .map_err(|error| std::io::Error::other(error.to_string()))? .ok_or(CodeIndexProductionErrorV1::Publication( @@ -871,7 +1107,6 @@ impl DaemonCodeIndexPublicationStoreV1 { active_path: store_root.join("active-code-generation-v1.json"), generations_root, segments_root, - segment_temporary_prefix, project_root: project_root.to_path_buf(), expected_sanitizer_revision, disposition: CodeIndexPublicationDispositionV1::Active, @@ -948,19 +1183,35 @@ impl DaemonCodeIndexPublicationStoreV1 { /// checking both before every segment keeps the blocking reconcile pass /// joinable inside the shutdown budget instead of forcing the coordinator /// to abandon it and the runtime teardown to wait for it again. - fn seal_checkpoint(&self) -> Result<(), CodeIndexProductionErrorV1> { - if self - .shutdown_signal - .as_ref() - .is_some_and(|shutting_down| shutting_down.load(Ordering::Acquire)) - || self - .reconcile_publication_fence + fn seal_checkpoint(&self) -> Result<(), CodeIndexPublicationStoreErrorV1> { + Self::publication_checkpoint( + self.shutdown_signal.as_deref(), + self.reconcile_publication_fence .as_ref() - .is_some_and(|(_, control)| control.is_cancelled()) - { - return Err(CodeIndexProductionErrorV1::Interrupted( - tracedecay_code_index::production::CodeIndexInterruptionV1::Cancelled, - )); + .map(|(_, control)| control), + ) + } + + fn publication_checkpoint( + shutdown_signal: Option<&AtomicBool>, + control: Option<&DaemonCodeIndexControlV1>, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + if shutdown_signal.is_some_and(|signal| signal.load(Ordering::Acquire)) { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + if let Some(control) = control { + // Sampling cancellation first latches a resident-watermark trip. + // The control preserves shutdown and source-supersession precedence + // even when an earlier sample already refused memory. + if control.is_cancelled() { + return Err(if control.refused_by_resident_memory() { + CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused( + "resident memory watermark refused code-index publication".to_owned(), + ) + } else { + CodeIndexPublicationStoreErrorV1::CompareAndSwap + }); + } } Ok(()) } @@ -1068,16 +1319,6 @@ impl DaemonCodeIndexPublicationStoreV1 { .map_err(Self::unavailable) } - fn write_durable(path: &Path, bytes: &[u8]) -> Result<(), CodeIndexPublicationStoreErrorV1> { - let mut file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(path) - .map_err(Self::unavailable)?; - file.write_all(bytes).map_err(Self::unavailable)?; - file.sync_all().map_err(Self::unavailable) - } - /// Writes one new sealed segment under a temporary name in `staged`, or /// verifies the already-named segment with its content address. A new /// segment takes its name only when [`StagedGenerationSegmentsV1::publish`] @@ -1088,7 +1329,9 @@ impl DaemonCodeIndexPublicationStoreV1 { skip_all )] fn stage_segment( - &self, + segments_root: &Path, + staging_root: &Path, + existing_segment_bytes_read: &AtomicU64, digest: &ManifestDigest, bytes: &[u8], staged: &mut StagedGenerationSegmentsV1, @@ -1101,13 +1344,10 @@ impl DaemonCodeIndexPublicationStoreV1 { "sealed segment bytes do not match their content address", )); } - let final_path = self - .segments_root - .join(format!("segment-{digest_hex}.json")); + let final_path = segments_root.join(format!("segment-{digest_hex}.json")); match final_path.symlink_metadata() { Ok(metadata) => { - self.seal_existing_segment_bytes_read - .fetch_add(metadata.len(), Ordering::Relaxed); + existing_segment_bytes_read.fetch_add(metadata.len(), Ordering::Relaxed); if !metadata.file_type().is_file() || metadata.len() != u64::try_from(bytes.len()).map_err(Self::unavailable)? || Self::state_digest_file(&final_path)? != expected_digest @@ -1124,22 +1364,7 @@ impl DaemonCodeIndexPublicationStoreV1 { if staged.contains(&final_path) { return Ok(()); } - let temporary_path = self.segments_root.join(format!( - ".segment-publication.{}.{}.tmp", - self.segment_temporary_prefix, digest_hex - )); - match temporary_path.symlink_metadata() { - Ok(metadata) if metadata.file_type().is_file() => { - std::fs::remove_file(&temporary_path).map_err(Self::unavailable)?; - } - Ok(_) => { - return Err(Self::unavailable( - "sealed segment temporary path is not a regular file", - )); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(Self::unavailable(error)), - } + let temporary_path = staging_root.join(format!("segment-{digest_hex}.json")); let mut file = std::fs::OpenOptions::new() .create_new(true) .write(true) @@ -1480,17 +1705,19 @@ impl DaemonCodeIndexPublicationStoreV1 { _ => CodeIndexPublicationStoreErrorV1::CompareAndSwap, }); } - let temporary = self + let store_root = self .active_path - .with_extension(format!("json.{}.tmp", std::process::id())); - if temporary.exists() { - std::fs::remove_file(&temporary).map_err(Self::unavailable)?; - } - Self::write_durable(&temporary, bytes)?; - if let Err(error) = std::fs::rename(&temporary, &self.active_path) { - let _ = std::fs::remove_file(&temporary); - return Err(Self::map_pointer_io(error)); - } + .parent() + .ok_or_else(|| Self::unavailable("active pointer has no parent directory"))?; + let mut temporary = tempfile::Builder::new() + .prefix(".active-code-generation-") + .tempfile_in(store_root) + .map_err(Self::unavailable)?; + temporary.write_all(bytes).map_err(Self::unavailable)?; + temporary.as_file().sync_all().map_err(Self::unavailable)?; + temporary + .persist(&self.active_path) + .map_err(|error| Self::map_pointer_io(error.error))?; Self::sync_directory( self.active_path .parent() @@ -1571,7 +1798,23 @@ impl DaemonCodeIndexPublicationStoreV1 { request: SealedGenerationSegmentReadV1<'_>, buffer: &mut Vec, ) -> Result<(), CodeIndexProductionErrorV1> { - let (digest, expected_size, offset, length) = match request { + let digest = match request { + SealedGenerationSegmentReadV1::Whole { digest, .. } + | SealedGenerationSegmentReadV1::Range { digest, .. } => digest, + }; + let digest_hex = sha256_hex_suffix(digest.as_str()).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("sealed segment digest is not sha256".to_owned()) + })?; + let path = segments_root.join(format!("segment-{digest_hex}.json")); + Self::read_segment_at(&path, request, buffer) + } + + fn read_segment_at( + path: &Path, + request: SealedGenerationSegmentReadV1<'_>, + buffer: &mut Vec, + ) -> Result<(), CodeIndexProductionErrorV1> { + let (_digest, expected_size, offset, length) = match request { SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { (digest, size_bytes, 0, size_bytes) } @@ -1590,10 +1833,6 @@ impl DaemonCodeIndexPublicationStoreV1 { "sealed generation segment range exceeds its manifest identity".to_owned(), )); } - let digest_hex = sha256_hex_suffix(digest.as_str()).ok_or_else(|| { - CodeIndexProductionErrorV1::Contract("sealed segment digest is not sha256".to_owned()) - })?; - let path = segments_root.join(format!("segment-{digest_hex}.json")); let metadata = path.symlink_metadata().map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "sealed generation segment is unavailable: {error}" @@ -2696,14 +2935,14 @@ impl DaemonCodeIndexPublicationStoreV1 { Ok(receipt) } - pub(super) fn take_unpublished(&self) -> Option { + pub(super) fn take_unpublished(&self) -> Option { self.unpublished_candidate .lock() .unwrap_or_else(PoisonError::into_inner) .take() } - pub(super) fn restore_unpublished(&self, generation: CodeIndexSealedPublicationV1) { + pub(super) fn restore_unpublished(&self, generation: UnpublishedCodeIndexPublicationV1) { let mut candidate = self .unpublished_candidate .lock() @@ -2715,6 +2954,8 @@ impl DaemonCodeIndexPublicationStoreV1 { } impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { + type Preparation = DaemonCodeIndexPreparationV1; + fn load_active( &self, _scope: &CodeIndexGenerationScopeV1, @@ -2727,106 +2968,240 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { })) } - #[tracing::instrument(name = "code_index.generation.publish", level = "trace", skip_all)] - fn publish_atomically( + fn prepare( &mut self, - _scope: &CodeIndexGenerationScopeV1, + scope: &CodeIndexGenerationScopeV1, expected_active_generation: Option<&CodeGenerationId>, - generation: &CodeIndexSealedPublicationV1, - ) -> Result, CodeIndexPublicationStoreErrorV1> { - *self - .unpublished_candidate - .lock() - .unwrap_or_else(PoisonError::into_inner) = Some(generation.clone()); - if self - .reconcile_publication_fence - .as_ref() - .is_some_and(|(_, control)| control.is_cancelled()) - { - return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); - } + ) -> Result { + scope.validate().map_err(Self::unavailable)?; + self.seal_checkpoint()?; let store_root = self .active_path .parent() .ok_or_else(|| Self::unavailable("active code-generation pointer has no store root"))?; + let retention = match acquire_generation_segments_publication_lock(store_root, &|| { + self.seal_checkpoint().is_err() + }) { + Ok(retention) => retention, + Err(CodeGenerationRetentionErrorV1::Cancelled) => { + self.seal_checkpoint()?; + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + Err(error) => return Err(Self::unavailable(error)), + }; + self.seal_checkpoint()?; + // Manifest abstention still binds the exact incumbent this rebuild + // replaces. This happens before any exclusive scope transaction. let undecoded_expectation = match self.undecoded_active_expectation.clone() { Some(expectation) => Some(expectation), - None => { - // Reading the active manifest takes the store lock itself. - // Complete it before entering the publication transaction, - // then recheck the exact expected pointer under the writer - // lock below. - match self.load_active_manifest()? { - Some(_) => None, - // An active pointer whose sealed generation this build - // abstains from decoding, a retired format revision, a - // superseded sanitizer, is still the incumbent this - // publication replaces, and its caller has no decoded - // generation id to expect. The compare-and-swap token is - // then the pointer identity the abstention observed, - // rechecked under the writer lock below; without it a - // store holding an undecodable generation could never be - // replaced by the rebuild that supersedes it. - None => self - .read_publication_pointer()? + None => match self.load_active_manifest()? { + Some(_) => None, + None => self + .read_publication_pointer()? + .as_ref() + .map(UndecodedActivePublicationExpectationV1::from_pointer), + }, + }; + let (incumbent_bytes, pointer) = { + let _read = self.acquire_generation_read_lock()?; + let pointer = self.read_publication_pointer()?; + let bytes = if pointer.is_some() { + Some(std::fs::read(&self.active_path).map_err(Self::unavailable)?) + } else { + None + }; + (bytes, pointer) + }; + match undecoded_expectation.as_ref() { + Some(expected) + if expected_active_generation.is_none() + && pointer .as_ref() - .map(UndecodedActivePublicationExpectationV1::from_pointer), - } - } + .is_some_and(|pointer| expected.matches(pointer)) => {} + None if pointer + .as_ref() + .map(|pointer| pointer.generation_id.as_str()) + == expected_active_generation.map(CodeGenerationId::as_str) => {} + _ => return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap), + } + if self.disposition == CodeIndexPublicationDispositionV1::RetainedHistory + && pointer.is_none() + { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + let store_identity = Handle::from_path(store_root).map_err(Self::unavailable)?; + let segments_identity = + Handle::from_path(&self.segments_root).map_err(Self::unavailable)?; + let generations_identity = + Handle::from_path(&self.generations_root).map_err(Self::unavailable)?; + self.seal_checkpoint()?; + let directory = tempfile::Builder::new() + .prefix(CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1) + .tempdir_in(&self.segments_root) + .map_err(Self::unavailable)?; + let evidence = + TemporaryEvidencePackV1::create(directory.path().join("generation-evidence.tmp"))?; + self.seal_encoded_segment_bytes.store(0, Ordering::Relaxed); + self.seal_existing_segment_bytes_read + .store(0, Ordering::Relaxed); + self.seal_evidence_page_count.store(0, Ordering::Relaxed); + self.seal_evidence_durable_transaction_count + .store(0, Ordering::Relaxed); + Ok(DaemonCodeIndexPreparationV1 { + scope: scope.clone(), + expected_active_generation: expected_active_generation.cloned(), + undecoded_expectation, + incumbent_bytes, + store_identity, + segments_identity, + generations_identity, + cache: Arc::clone(&self.cache), + active_path: self.active_path.clone(), + segments_root: self.segments_root.clone(), + disposition: self.disposition, + state: Arc::new(Mutex::new(DaemonCodeIndexPreparationStateV1::Open( + Box::new(DaemonCodeIndexPreparationFilesV1 { + directory, + segments: StagedGenerationSegmentsV1::new(), + evidence, + _retention: retention, + referenced_segment_bytes: 0, + manifest_identity: None, + pending_pointer: None, + segments_durable: false, + }), + ))), + shutdown_signal: self.shutdown_signal.clone(), + capture_control: self + .reconcile_publication_fence + .as_ref() + .map(|(_, control)| control.clone()), + encoded_segment_bytes: Arc::clone(&self.seal_encoded_segment_bytes), + existing_segment_bytes_read: Arc::clone(&self.seal_existing_segment_bytes_read), + evidence_page_count: Arc::clone(&self.seal_evidence_page_count), + evidence_durable_transaction_count: Arc::clone( + &self.seal_evidence_durable_transaction_count, + ), + #[cfg(test)] + segment_observer: self.seal_segment_observer.clone(), + }) + } + + #[tracing::instrument(name = "code_index.generation.publish", level = "trace", skip_all)] + fn commit( + &mut self, + preparation: &mut Self::Preparation, + generation: &CodeIndexSealedPublicationV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + let result = self.commit_prepared(preparation, generation); + if matches!( + &result, + Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap) + ) { + preparation.invalidate_uncommitted(); + } + result + } +} + +impl DaemonCodeIndexPublicationStoreV1 { + fn commit_prepared( + &self, + preparation: &DaemonCodeIndexPreparationV1, + generation: &CodeIndexSealedPublicationV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + self.seal_checkpoint()?; + let store_root = self + .active_path + .parent() + .ok_or_else(|| Self::unavailable("active code-generation pointer has no store root"))?; + self.validate_preparation_store(preparation, generation)?; + let mut session = preparation + .state + .lock() + .map_err(|_| DaemonCodeIndexPreparationV1::closed())?; + let DaemonCodeIndexPreparationStateV1::Open(files) = &mut *session else { + return Err(DaemonCodeIndexPreparationV1::closed()); }; + let manifest_bytes = generation.manifest_bytes(); + let generation_size = u64::try_from(manifest_bytes.len()).map_err(Self::unavailable)?; + if generation_size > MAX_DURABLE_GENERATION_INDEX_BYTES_V1 { + return Err(Self::unavailable( + "sealed code generation exceeds the durable history byte bound", + )); + } + let manifest_identity = Self::state_digest(manifest_bytes); + if files + .manifest_identity + .as_ref() + .is_some_and(|digest| digest != &manifest_identity) + { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + files.manifest_identity = Some(manifest_identity); + // A verified manifest from another preparation proves no ownership of + // that preparation's private bytes. Authenticate every named segment + // against this session's staged files or the canonical shared store. + // Stream under the shared retention lease, before the final scope and + // cache locks; canonical names are immutable and staging is frozen. + self.verify_prepared_segments(files, manifest_bytes)?; let _store_lock = try_acquire_code_generation_store_lock(store_root) .map_err(Self::unavailable)? .ok_or(CodeIndexPublicationStoreErrorV1::StoreLockContended)?; - let prior_pointer = if let Some(expected) = undecoded_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 { - self.read_publication_pointer()? - }; - // The bytes behind `prior_pointer`, captured under the store lock. - // The commit below refuses to rename unless the file is still these - // exact bytes, so a pointer that changed after this observation is - // not overwritten. + self.validate_preparation_store(preparation, generation)?; + let prior_pointer = self.read_publication_pointer()?; let prior_bytes = if prior_pointer.is_some() { Some(std::fs::read(&self.active_path).map_err(Self::unavailable)?) } else { None }; - if undecoded_expectation.is_none() - && prior_pointer + let pointer_already_installed = files.pending_pointer.as_deref().is_some() + && files.pending_pointer.as_deref() == prior_bytes.as_deref(); + if !pointer_already_installed && prior_bytes != preparation.incumbent_bytes { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + let expected_active_generation = preparation.expected_active_generation.as_ref(); + let undecoded_expectation = preparation.undecoded_expectation.as_ref(); + if !pointer_already_installed { + if let Some(expected) = undecoded_expectation { + if expected_active_generation.is_some() + || !prior_pointer + .as_ref() + .is_some_and(|pointer| expected.matches(pointer)) + { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + } else if prior_pointer .as_ref() .map(|pointer| pointer.generation_id.as_str()) != expected_active_generation.map(CodeGenerationId::as_str) - { - return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } } - // A decode cached for another generation than the pointer names is - // a stale read the publication must not race; an empty cache is the - // ordinary state after a sparse successor or a released decode. - let state = self.cache.lock_state()?; - let cached_active = state - .active - .as_ref() - .map(|current| ¤t.manifest().generation_id); - let cache_matches = cached_active.is_none_or(|cached| { - undecoded_expectation - .as_ref() - .map_or(Some(cached) == expected_active_generation, |expected| { - cached.as_str() == expected.generation_id.as_str() - }) - }); - if !cache_matches { + let cache = self.cache.lock_state()?; + if !preparation.cache_matches( + &cache, + pointer_already_installed.then_some(&generation.manifest().generation_id), + ) { return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); } - drop(state); + if pointer_already_installed { + // The pointer rename succeeded but its directory fsync failed. + // Only this session's exact planned bytes authorize completing it. + drop(cache); + let source_fence = self.publication_source_fence()?; + let mut cache = self.cache.lock_state()?; + if !preparation.cache_matches(&cache, Some(&generation.manifest().generation_id)) { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + Self::sync_directory(store_root)?; + self.finish_prepared_cache(&mut cache, generation, generation_size); + drop(source_fence); + *session = DaemonCodeIndexPreparationStateV1::Committed; + return Ok(Arc::clone(manifest_bytes)); + } + drop(cache); if self.disposition == CodeIndexPublicationDispositionV1::RetainedHistory && prior_pointer.is_none() { @@ -2841,7 +3216,7 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { "retained history generation aliases the active generation identity", )); } - let parent_manifest_bytes = expected_active_generation + if let Some(entry) = expected_active_generation .filter(|expected| generation.manifest().parent_generation.as_ref() == Some(*expected)) .and_then(|expected| { prior_pointer.as_ref().and_then(|pointer| { @@ -2851,284 +3226,83 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { .find(|entry| entry.generation_id == expected.as_str()) }) }) - .map(|entry| { - Self::validate_generation_file(&entry.generation_file)?; - let path = self.generations_root.join(&entry.generation_file); - let metadata = path.symlink_metadata().map_err(Self::unavailable)?; - if !metadata.file_type().is_file() || metadata.len() != entry.size_bytes { - return Err(Self::corruption( - "parent generation manifest identity is corrupt", - )); - } - let bytes = std::fs::read(path).map_err(Self::unavailable)?; - if Self::state_digest(&bytes) != entry.state_digest { - return Err(Self::corruption( - "parent generation manifest digest does not verify", - )); - } - Ok(bytes) - }) - .transpose()?; - let temporary_path = self.generations_root.join(format!( - ".generation-publication.{}.tmp", - std::process::id() - )); - match temporary_path.symlink_metadata() { - Ok(metadata) if metadata.file_type().is_file() => { - std::fs::remove_file(&temporary_path).map_err(Self::unavailable)?; - } - Ok(_) => { - return Err(Self::unavailable( - "sealed code-generation temporary path is not a regular file", + { + Self::validate_generation_file(&entry.generation_file)?; + let path = self.generations_root.join(&entry.generation_file); + let metadata = path.symlink_metadata().map_err(Self::unavailable)?; + if !metadata.file_type().is_file() + || metadata.len() != entry.size_bytes + || Self::state_digest_file(&path)? != entry.state_digest + { + return Err(Self::corruption( + "parent generation manifest identity is corrupt", )); } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(Self::unavailable(error)), } - let mut temporary = TemporaryGenerationFileV1::new(temporary_path); - // From the first segment write until the manifest naming them is - // durable, no sweep over the project's shared segments may run. - let segments_lock = acquire_generation_segments_publication_lock(store_root, &|| { - self.seal_checkpoint().is_err() - }) - .map_err(|error| match error { - CodeGenerationRetentionErrorV1::Cancelled => { - CodeIndexPublicationStoreErrorV1::CompareAndSwap - } - error => Self::unavailable(error), - })?; - let evidence_temporary_path = self.segments_root.join(format!( - ".evidence-pack-publication.{}.tmp", - self.segment_temporary_prefix - )); - let mut evidence_pack = TemporaryEvidencePackV1::create(evidence_temporary_path)?; - let mut staged_segments = StagedGenerationSegmentsV1::new(); - let mut referenced_segment_bytes = 0_u64; - self.seal_encoded_segment_bytes.store(0, Ordering::Relaxed); - self.seal_existing_segment_bytes_read - .store(0, Ordering::Relaxed); - self.seal_evidence_page_count.store(0, Ordering::Relaxed); - self.seal_evidence_durable_transaction_count - .store(0, Ordering::Relaxed); - let manifest_bytes = { - let _span = - tracing::trace_span!("code_index.generation.publish.segment_encode").entered(); - generation.encode(parent_manifest_bytes.as_deref(), |publication| { - self.seal_checkpoint()?; - match publication { - SealedGenerationSegmentPublicationV1::File { digest, bytes } => { - let segment_size = u64::try_from(bytes.len()).map_err(|_| { - CodeIndexProductionErrorV1::Contract( - "sealed segment length exceeds u64".to_owned(), - ) - })?; - { - let _span = tracing::trace_span!( - "code_index.generation.publish.segment_durable" - ) - .entered(); - self.stage_segment(digest, bytes, &mut staged_segments) - } - .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - #[cfg(test)] - if let Some(observer) = self.seal_segment_observer.as_ref() { - observer(); - } - referenced_segment_bytes = - referenced_segment_bytes.saturating_add(segment_size); - self.seal_encoded_segment_bytes - .fetch_add(segment_size, Ordering::Relaxed); - } - SealedGenerationSegmentPublicationV1::FileEvidence { digest, bytes } - | SealedGenerationSegmentPublicationV1::ResolutionIndex { digest, bytes } => { - let segment_size = u64::try_from(bytes.len()).map_err(|_| { - CodeIndexProductionErrorV1::Contract( - "sealed file evidence length exceeds u64".to_owned(), - ) - })?; - { - let _span = tracing::trace_span!( - "code_index.generation.publish.file_evidence_durable" - ) - .entered(); - self.stage_segment(digest, bytes, &mut staged_segments) - } - .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - referenced_segment_bytes = - referenced_segment_bytes.saturating_add(segment_size); - } - SealedGenerationSegmentPublicationV1::CodeGraphPage { - page_digest, - bytes, - .. - } => { - let segment_size = u64::try_from(bytes.len()).map_err(|_| { - CodeIndexProductionErrorV1::Contract( - "sealed graph page length exceeds u64".to_owned(), - ) - })?; - { - let _span = tracing::trace_span!( - "code_index.generation.publish.graph_page_durable" - ) - .entered(); - self.stage_segment(page_digest, bytes, &mut staged_segments) - } - .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - referenced_segment_bytes = - referenced_segment_bytes.saturating_add(segment_size); - } - SealedGenerationSegmentPublicationV1::GenerationEvidencePage { - page_ordinal, - page_digest, - bytes, - } => { - { - let _span = tracing::trace_span!( - "code_index.generation.publish.evidence_page_append" - ) - .entered(); - evidence_pack.append_page(page_ordinal, page_digest, bytes) - } - .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - self.seal_evidence_page_count - .fetch_add(1, Ordering::Relaxed); - } - SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { - segment_digest, - segment_size_bytes, - page_count, - } => { - if { - let _span = tracing::trace_span!( - "code_index.generation.publish.evidence_commit" - ) - .entered(); - evidence_pack.commit( - &self.segments_root, - segment_digest, - segment_size_bytes, - page_count, - ) - } - .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))? - { - self.seal_evidence_durable_transaction_count - .fetch_add(1, Ordering::Relaxed); - } - referenced_segment_bytes = - referenced_segment_bytes.saturating_add(segment_size_bytes); - } - } - Ok(()) - }) - }; - let manifest_bytes = match manifest_bytes { - Ok(bytes) => bytes, - Err(CodeIndexProductionErrorV1::Interrupted( - tracedecay_code_index::production::CodeIndexInterruptionV1::Cancelled, - )) => { - evidence_pack.rollback_unattached(&self.segments_root)?; - return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); - } - Err(error) => { - evidence_pack.rollback_unattached(&self.segments_root)?; - return Err(Self::unavailable(error)); - } - }; - // The seal's new segments become durable in one flush, take their - // names, and one directory fsync makes the renames durable. The - // manifest and active pointer stay unpublished until both succeed. - let named_new_segments = { - let _span = - tracing::trace_span!("code_index.generation.publish.segments_flush").entered(); - staged_segments.publish() - }; - let named_new_segments = match named_new_segments { - Ok(named) => named, - Err(error) => { - evidence_pack.rollback_unattached(&self.segments_root)?; - return Err(error); + if !files.segments_durable { + { + let _span = + tracing::trace_span!("code_index.generation.publish.segments_flush").entered(); + files.segments.publish()?; } - }; - if named_new_segments { + // A reused canonical name may have just been renamed by another + // preparation. Its contents were flushed before naming; this + // sync makes every referenced name durable before our manifest. { let _span = tracing::trace_span!("code_index.generation.publish.segments_dir_sync") .entered(); - Self::sync_directory(&self.segments_root) - }?; + Self::sync_directory(&self.segments_root)?; + } #[cfg(test)] if let Some(observer) = self.segments_dir_sync_observer.as_ref() { observer(); } + files.segments_durable = true; } - - let manifest_publication = (|| { - { + self.seal_checkpoint()?; + let mut temporary = tempfile::Builder::new() + .prefix(".generation-publication-") + .tempfile_in(&self.generations_root) + .map_err(Self::unavailable)?; + { + let _span = tracing::trace_span!("code_index.generation.publish.seal_fsync").entered(); + temporary + .write_all(manifest_bytes) + .map_err(Self::unavailable)?; + temporary.as_file().sync_all().map_err(Self::unavailable)?; + } + let state_digest = { + let _span = + tracing::trace_span!("code_index.generation.publish.state_digest").entered(); + Self::state_digest_file(temporary.path())? + }; + let suffix = sha256_hex_suffix(&state_digest) + .ok_or_else(|| Self::unavailable("sealed generation digest is not sha256"))?; + let generation_file = format!("generation-{suffix}.json"); + let generation_path = self.generations_root.join(&generation_file); + match generation_path.symlink_metadata() { + Ok(metadata) => { let _span = - tracing::trace_span!("code_index.generation.publish.seal_fsync").entered(); + tracing::trace_span!("code_index.generation.publish.dedupe_compare").entered(); + if !metadata.file_type().is_file() + || !Self::files_equal(&generation_path, temporary.path())? { - Self::write_durable(&temporary.path, &manifest_bytes)?; - Ok::<(), CodeIndexPublicationStoreErrorV1>(()) + return Err(Self::corruption( + "immutable code-generation path contains different bytes", + )); } - }?; - let generation_size = u64::try_from(manifest_bytes.len()).map_err(Self::unavailable)?; - if generation_size > MAX_DURABLE_GENERATION_INDEX_BYTES_V1 { - return Err(Self::unavailable( - "sealed code generation exceeds the durable history byte bound", - )); + temporary.close().map_err(Self::unavailable)?; } - let state_digest = { - let _span = - tracing::trace_span!("code_index.generation.publish.state_digest").entered(); - Self::state_digest_file(&temporary.path) - }?; - - let generation_file = format!( - "generation-{}.json", - sha256_hex_suffix(&state_digest).unwrap_or(&state_digest) - ); - let generation_path = self.generations_root.join(&generation_file); - match generation_path.symlink_metadata() { - Ok(_) => { - let equal = { - let _span = - tracing::trace_span!("code_index.generation.publish.dedupe_compare") - .entered(); - Self::files_equal(&generation_path, &temporary.path) - }?; - if !equal { - return Err(Self::unavailable( - "immutable code-generation path contains different bytes", - )); - } - std::fs::remove_file(&temporary.path).map_err(Self::unavailable)?; - temporary.commit(); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::rename(&temporary.path, &generation_path) - .map_err(Self::unavailable)?; - temporary.path = generation_path; - Self::sync_directory(&self.generations_root)?; - temporary.commit(); - } - Err(error) => return Err(Self::unavailable(error)), - } - Ok((generation_size, state_digest, generation_file)) - })(); - let (generation_size, state_digest, generation_file) = match manifest_publication { - Ok(published) => published, - Err(error) => { - let generation_rollback = temporary.rollback_uncommitted(&self.generations_root); - let evidence_rollback = evidence_pack.rollback_unattached(&self.segments_root); - generation_rollback?; - evidence_rollback?; - return Err(error); + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // The manifest is immutable once named. Failure cleanup never + // deletes a canonical name another scope may have attached. + temporary + .persist(&generation_path) + .map_err(|error| Self::unavailable(error.error))?; } - }; - evidence_pack.attach_to_manifest(); - drop(segments_lock); - + Err(error) => return Err(Self::unavailable(error)), + } + Self::sync_directory(&self.generations_root)?; let exact_git_evidence = self.exact_git_evidence(generation.snapshot())?; let mut generation_index = prior_pointer .as_ref() @@ -3148,7 +3322,7 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { snapshot_content_identity: generation.snapshot().content_identity.as_str().to_owned(), sealed_at_micros: generation.manifest().seal.sealed_at.0, size_bytes: generation_size, - segment_bytes: referenced_segment_bytes, + segment_bytes: files.referenced_segment_bytes, generation_file: generation_file.clone(), state_digest: state_digest.clone(), source_reference: exact_git_evidence @@ -3214,79 +3388,127 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { "publication pointer serialization failed: {error}" )) })?; - // Serialize the final visibility boundary with observed-change wakes. - // Immutable segment writes may finish before this point, but a stale - // capture cannot replace the active durable pointer. - let source_fence = if let Some((hints, control)) = self.reconcile_publication_fence.as_ref() - { - let guard = hints - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if control.is_cancelled() { - return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); - } - Some(guard) - } else { - None - }; + files.pending_pointer = Some(bytes.clone()); + // The current handle owns the refreshed source fence on every attempt. + let source_fence = self.publication_source_fence()?; + let mut cache = self.cache.lock_state()?; + if !preparation.cache_matches(&cache, None) { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } { let _span = tracing::trace_span!("code_index.generation.publish.pointer_commit").entered(); - self.commit_observed_pointer(&_store_lock, prior_bytes.as_deref(), &pointer, &bytes) - }?; + self.commit_observed_pointer(&_store_lock, prior_bytes.as_deref(), &pointer, &bytes)?; + } drop(source_fence); - let charges = generation - .decoded() - .filter(|_| matches!(self.disposition, CodeIndexPublicationDispositionV1::Active)) - .map(|decoded| Self::active_generation_charges(decoded)); - let mut state = self.cache.lock_state()?; - if undecoded_expectation.is_none() { - let cached_active = state - .active - .as_ref() - .map(|current| ¤t.manifest().generation_id); - if cached_active.is_some_and(|cached| Some(cached) != expected_active_generation) { - return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); - } + self.finish_prepared_cache(&mut cache, generation, generation_size); + *session = DaemonCodeIndexPreparationStateV1::Committed; + Ok(Arc::clone(manifest_bytes)) + } + + fn validate_preparation_store( + &self, + preparation: &DaemonCodeIndexPreparationV1, + generation: &CodeIndexSealedPublicationV1, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + let store_root = self + .active_path + .parent() + .ok_or_else(|| Self::unavailable("active code-generation pointer has no store root"))?; + if self.active_path != preparation.active_path + || !Arc::ptr_eq(&self.cache, &preparation.cache) + || Handle::from_path(store_root).map_err(Self::unavailable)? + != preparation.store_identity + || Handle::from_path(&self.segments_root).map_err(Self::unavailable)? + != preparation.segments_identity + || Handle::from_path(&self.generations_root).map_err(Self::unavailable)? + != preparation.generations_identity + || self.disposition != preparation.disposition + || CodeIndexGenerationScopeV1::for_snapshot(generation.snapshot()) != preparation.scope + { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); } - let generation_id = generation.manifest().generation_id.clone(); - state.forget(&generation_id); - match self.disposition { - CodeIndexPublicationDispositionV1::Active => { - // 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); - // Graph-off undecoded rebuild already holds the sealed - // generation. Clearing `state.active` forced the next pass to - // cold-decode the whole store, so a retry after a transient - // publication failure never published the successor before its - // deadline. Install the built generation in both dispositions. - self.active_encoded_bytes - .store(generation_size, Ordering::Release); - if let Some(charges) = charges { - self.record_active_generation_charges(charges); + Ok(()) + } + + fn verify_prepared_segments( + &self, + files: &DaemonCodeIndexPreparationFilesV1, + manifest: &[u8], + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + let identities = + CodeIndexPublishedGenerationV1::partitioned_segment_identities_from_reader(manifest) + .map_err(Self::corruption)? + .ok_or_else(|| { + Self::corruption("prepared generation manifest has an incompatible revision") + })?; + let mut verified = BTreeMap::new(); + for identity in identities { + self.seal_checkpoint()?; + if let Some(size) = verified.get(&identity.digest) { + if *size != identity.size_bytes { + return Err(Self::corruption( + "prepared segment carries conflicting manifest extents", + )); } - state.active = generation.decoded().map(Arc::clone); + continue; } - CodeIndexPublicationDispositionV1::RetainedHistory => { - if let Some(decoded) = generation.decoded() { - state.decoded.push_back(Arc::clone(decoded)); - } - while state.decoded.len() > DECODED_GENERATION_CACHE_CAPACITY { - state.decoded.pop_front(); + let suffix = sha256_hex_suffix(identity.digest.as_str()) + .ok_or_else(|| Self::corruption("prepared segment digest is not sha256"))?; + let canonical = self.segments_root.join(format!("segment-{suffix}.json")); + let path = files.segments.pending.get(&canonical).unwrap_or(&canonical); + let metadata = path.symlink_metadata().map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Self::corruption("prepared generation names a missing segment") + } else { + Self::unavailable(error) } + })?; + if !metadata.file_type().is_file() + || metadata.len() != identity.size_bytes + || Self::state_digest_file(path)? != identity.digest.as_str() + { + return Err(Self::corruption( + "prepared generation segment does not match its manifest identity", + )); } + verified.insert(identity.digest, identity.size_bytes); + } + Ok(()) + } + + fn publication_source_fence( + &self, + ) -> Result>, CodeIndexPublicationStoreErrorV1> { + let guard = self + .reconcile_publication_fence + .as_ref() + .map(|(hints, _)| hints.lock().unwrap_or_else(PoisonError::into_inner)); + self.seal_checkpoint()?; + Ok(guard) + } + + fn finish_prepared_cache( + &self, + state: &mut DecodedGenerationStateV1, + generation: &CodeIndexSealedPublicationV1, + generation_size: u64, + ) { + state.forget(&generation.manifest().generation_id); + if self.disposition == CodeIndexPublicationDispositionV1::Active { + state.active_epoch = state.active_epoch.wrapping_add(1); + state.active = None; + self.active_encoded_bytes + .store(generation_size, Ordering::Release); + *self + .active_decode_charge + .lock() + .unwrap_or_else(PoisonError::into_inner) = None; } - drop(state); *self .active_manifest_memo .lock() .unwrap_or_else(PoisonError::into_inner) = None; - *self - .unpublished_candidate - .lock() - .unwrap_or_else(PoisonError::into_inner) = None; - Ok(Arc::from(manifest_bytes)) + self.cache.ready.notify_all(); } } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/reconcile.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/reconcile.rs index d8bbfe3f5e..5086ec661c 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/reconcile.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/reconcile.rs @@ -42,8 +42,8 @@ use tracedecay_runtime_core::resident_memory::ResidentMemoryPressureV1; use tracedecay_runtime_core::resident_memory::{ ProcessResidentMemoryV1, RESIDENT_MEMORY_PRESSURE_ADMISSION_FLOOR_BYTES_V1, RESIDENT_OWNER_IDLE_WINDOW_V1, ResidentMemoryAdmissionFailureV1, ResidentMemoryComponentIdV1, - ResidentMemoryKeyV1, ResidentMemoryReservationV1, ResidentOwnersV1, - detected_process_resident_memory_limit_v1, + ResidentMemoryKeyV1, ResidentMemoryReconstructionDemandV1, ResidentMemoryReservationV1, + ResidentOwnersV1, detected_process_resident_memory_limit_v1, }; use tracedecay_code_index::{ @@ -53,9 +53,10 @@ use tracedecay_code_index::{ CodeIndexAtomicPublicationPort, CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, CodeIndexExecutionControlV1, CodeIndexGenerationCompatibilityV1, CodeIndexGenerationScopeV1, CodeIndexIgnoredSourceAdmissionV1, CodeIndexInputErrorV1, - CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexPublicationStoreErrorV1, - CodeIndexPublishedBuildV1, CodeIndexPublishedGenerationV1, - CodeIndexRepositoryParseIdentityV1, DAEMON_CODE_INDEX_CHUNKER_REVISION, + CodeIndexPreparationPort, CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, + CodeIndexPublicationStoreErrorV1, CodeIndexPublishedBuildV1, + CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, + CodeIndexSealedGenerationV1, DAEMON_CODE_INDEX_CHUNKER_REVISION, VerifiedSealedTextGenerationMetadataV1, }, }; @@ -65,7 +66,7 @@ use super::freshness_witness::{ SourceSweepCacheV1, }; use super::git_tree_capture::{CapturedFileOutcomeV1, CapturedFileRosterV1}; -use super::publication_store::GenerationDecodeBudgetV1; +use super::publication_store::{GenerationDecodeBudgetV1, UnpublishedCodeIndexPublicationV1}; use super::{ CodeGraphActivationStateV1, CodeGraphReplayBindingV1, CodeIndexBuildProgressSlotStateV1, CodeIndexBuildProgressSlotV1, CodeIndexBuildProgressStateV1, CodeIndexHintPolicyV1, @@ -270,6 +271,16 @@ pub enum CodeIndexSchedulerErrorV1 { IgnoredDependency(#[from] CodeIndexIgnoredDependencyRefusalV1), #[error("code-index worker resident-memory admission refused: {0}")] WorkerMemoryAdmission(#[from] ResidentMemoryAdmissionFailureV1), + #[error("code-index reconstruction resident-memory admission refused: {detail}")] + ReconstructionMemoryRefused { + detail: String, + source_epoch: u64, + demand: ResidentMemoryReconstructionDemandV1, + }, + #[error("code-index prepared publication awaits commit: {error}")] + PreparedPublicationRefused { + error: CodeIndexPublicationStoreErrorV1, + }, #[error("code-index retained-source resident-memory admission refused: {0}")] SnapshotMemoryAdmission(ResidentMemoryAdmissionFailureV1), #[error("code-index retained-source resident-memory capacity is unavailable")] @@ -320,6 +331,8 @@ impl CodeIndexSchedulerErrorV1 { | Self::PublicationConflict(_) | Self::IgnoredDependency(_) | Self::WorkerMemoryAdmission(_) + | Self::ReconstructionMemoryRefused { .. } + | Self::PreparedPublicationRefused { .. } | Self::SnapshotMemoryAdmission(_) | Self::SnapshotMemoryCapacityUnavailable | Self::WorkerPlan(_) => false, @@ -442,7 +455,9 @@ impl CodeIndexSchedulerErrorV1 { self, Self::Production(CodeIndexProductionErrorV1::Publication( CodeIndexPublicationStoreErrorV1::StoreLockContended, - )) + )) | Self::PreparedPublicationRefused { + error: CodeIndexPublicationStoreErrorV1::StoreLockContended + } ) } @@ -450,6 +465,11 @@ impl CodeIndexSchedulerErrorV1 { /// opposed to a held store lock or a graph operation budget. pub(crate) fn is_resident_memory_refusal(&self) -> bool { match self { + Self::ReconstructionMemoryRefused { .. } => true, + Self::PreparedPublicationRefused { error } => matches!( + error, + CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused(_) + ), Self::WorkerMemoryAdmission(failure) | Self::SnapshotMemoryAdmission(failure) => { failure.is_observed_over_budget() || failure.requested_bytes() <= failure.limit_bytes() @@ -974,7 +994,7 @@ pub struct CodeIndexWorktreeSchedulerV1 { /// Process resident-memory authority artifact builds and readers reserve /// through. Standalone opens get a private default-limit authority; the /// registry rebinds its shared process authority at mount. - resident_memory: Arc, + pub(super) resident_memory: Arc, /// Retained owners an artifact build sheds before its reservation is /// refused. Standalone opens get a private empty inventory; the registry /// rebinds its inventory at mount. @@ -1476,26 +1496,10 @@ impl CodeIndexWorktreeSchedulerV1 { ) -> Result { let _workers = self.ensure_worker_plan()?; let planned_workers = tracedecay_code_index::parallelism::indexing_workers(); - // Planned from the view admission refuses against: a width the - // process cannot hold is refused on every retry. - let remaining = self - .resident_memory - .headroom_below(u64::MAX) - .available_bytes; - // The process-global worker plan may have been installed against a - // larger authority (standalone seed using detected host RAM). This - // scheduler's remaining bytes are a different authority: the 6 GiB - // default still has to leave the typed non-worker headroom that - // `memory_safe_worker_count` already models. Capping to - // `remaining / 128MiB` spends that headroom as extra workers, so a - // later 31-byte snapshot charge sees used==limit. A remainder that - // cannot admit one memory-safe worker still requests one so - // admission produces the canonical denial. - let affordable_workers = - tracedecay_code_index::parallelism::memory_safe_worker_count(remaining); - let workers = planned_workers.min(affordable_workers); + // This is the pool that will execute the pass. Refuse its actual + // scratch demand rather than charging a narrower plan we do not run. let requested_bytes = NonZeroU64::new( - tracedecay_code_index::parallelism::worker_reservation_bytes(workers.max(1)), + tracedecay_code_index::parallelism::worker_reservation_bytes(planned_workers.max(1)), ) .ok_or_else(|| { CodeIndexSchedulerErrorV1::Identity( @@ -1506,6 +1510,10 @@ impl CodeIndexWorktreeSchedulerV1 { .map_err(|error| CodeIndexSchedulerErrorV1::Identity(error.to_string()))?; let generation_id = CodeGenerationId::new("code-index-worker-active") .map_err(|error| CodeIndexSchedulerErrorV1::Identity(error.to_string()))?; + // A previously installed executor does not replan before each pass. + // Admission must still observe current process pressure before it + // reserves that executor's actual scratch demand. + self.resident_memory.pressure().sample_and_publish(); self.resident_memory .reserve( ResidentMemoryKeyV1 { @@ -1592,7 +1600,7 @@ impl CodeIndexWorktreeSchedulerV1 { /// Checkpoints read that cell directly. The maintenance sampler's cadence /// is longer than a linked-worktree capture, so a fence that only watched /// epoch and shutdown kept allocating until the cgroup kill line. - fn reconcile_control(&self) -> DaemonCodeIndexControlV1 { + pub(super) fn reconcile_control(&self) -> DaemonCodeIndexControlV1 { DaemonCodeIndexControlV1::new(Arc::clone(&self.epoch), Arc::clone(&self.shutting_down)) .with_resident_memory(Arc::clone(self.resident_memory.pressure())) } @@ -1748,6 +1756,44 @@ impl CodeIndexWorktreeSchedulerV1 { } } + fn commit_pending_publication( + &self, + mut pending: UnpublishedCodeIndexPublicationV1, + control: DaemonCodeIndexControlV1, + ) -> Result { + let mut store = self + .publication + .clone() + .with_reconcile_publication_fence(Arc::clone(&self.hints), control); + match store.commit(&mut pending.preparation, &pending.publication) { + Ok(bytes) => { + let sealed = + CodeIndexSealedGenerationV1::new(bytes, pending.preparation.segment_reader()); + CodeIndexPublishedBuildV1::new(pending.publication, sealed).map_err(Into::into) + } + Err(error) => { + if matches!( + error, + CodeIndexPublicationStoreErrorV1::Unavailable(_) + | CodeIndexPublicationStoreErrorV1::StoreLockContended + | CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused(_) + ) { + self.publication.restore_unpublished(pending); + } + if matches!( + error, + CodeIndexPublicationStoreErrorV1::Unavailable(_) + | CodeIndexPublicationStoreErrorV1::StoreLockContended + | CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused(_) + ) { + Err(CodeIndexSchedulerErrorV1::PreparedPublicationRefused { error }) + } else { + Err(CodeIndexProductionErrorV1::Publication(error).into()) + } + } + } + } + /// Record that `metadata`, just published, seals the tree the stat /// signature describes. fn persist_published_witness( @@ -2084,13 +2130,13 @@ impl CodeIndexWorktreeSchedulerV1 { pub fn republish_unpublished_retained_generation( &mut self, ) -> Result, CodeIndexSchedulerErrorV1> { - let Some(pointer) = self - .publication - .read_publication_pointer() - .map_err(CodeIndexProductionErrorV1::Publication)? - else { - return Ok(None); - }; + if let Some((preparation, publication)) = self.owner.take_unpublished() { + self.publication + .restore_unpublished(UnpublishedCodeIndexPublicationV1 { + preparation, + publication, + }); + } let Some(pending) = self.publication.take_unpublished() else { return Ok(None); }; @@ -2102,19 +2148,11 @@ impl CodeIndexWorktreeSchedulerV1 { } }; if !resolved.authorizes_reuse_of(&self.identity) { - self.publication.restore_unpublished(pending); return Err(CodeIndexSchedulerErrorV1::Identity( "worktree identity changed under the scheduler".to_owned(), )); } self.identity = resolved; - let _worker_memory = match self.reserve_incremental_rebuild_memory() { - Ok(reservation) => reservation, - Err(error) => { - self.publication.restore_unpublished(pending); - return Err(error); - } - }; let capture = match self.capture_retained_reconcile_attempt() { Ok(Some(capture)) => capture, Ok(None) => { @@ -2133,22 +2171,14 @@ impl CodeIndexWorktreeSchedulerV1 { git_metadata, stat_signature, } = capture; - if pending.snapshot().reference != captured.snapshot.reference - || pending.snapshot().source_revision != captured.snapshot.source_revision - || pending.snapshot().content_identity != captured.snapshot.content_identity + if pending.publication.snapshot().reference != captured.snapshot.reference + || pending.publication.snapshot().source_revision != captured.snapshot.source_revision + || pending.publication.snapshot().content_identity != captured.snapshot.content_identity { return Ok(None); } - let scope = CodeIndexGenerationScopeV1::for_snapshot(pending.snapshot()); let began_under = control.expected_epoch(); - let mut publication = self - .publication - .for_undecoded_active_rebuild(&pointer) - .with_reconcile_publication_fence(Arc::clone(&self.hints), control); - let manifest_bytes = publication - .publish_atomically(&scope, None, &pending) - .map_err(CodeIndexProductionErrorV1::Publication)?; - let generation = CodeIndexPublishedBuildV1::new(pending, manifest_bytes)?; + let generation = self.commit_pending_publication(pending, control)?; self.latest_content_identity = Some(generation.snapshot().content_identity.clone()); self.mark_reconciled_state( git_metadata.clone(), @@ -2319,7 +2349,6 @@ impl CodeIndexWorktreeSchedulerV1 { return Ok(None); } - let _worker_memory = self.reserve_incremental_rebuild_memory()?; let Some(capture) = self.capture_retained_reconcile_attempt()? else { return Ok(None); }; @@ -2336,7 +2365,6 @@ impl CodeIndexWorktreeSchedulerV1 { metadata: &VerifiedSealedTextGenerationMetadataV1, witness: Option, ) -> Result, CodeIndexSchedulerErrorV1> { - let _worker_memory = self.reserve_incremental_rebuild_memory()?; let Some(capture) = self.capture_retained_reconcile_attempt()? else { return Ok(None); }; @@ -2432,9 +2460,11 @@ impl CodeIndexWorktreeSchedulerV1 { let reextracted_files = captured.changed_paths.len(); self.freshness_fence.note_successor_build(); let pending = self.publication.take_unpublished().filter(|pending| { - pending.snapshot().reference == captured.snapshot.reference - && pending.snapshot().source_revision == captured.snapshot.source_revision - && pending.snapshot().content_identity == captured.snapshot.content_identity + pending.publication.snapshot().reference == captured.snapshot.reference + && pending.publication.snapshot().source_revision + == captured.snapshot.source_revision + && pending.publication.snapshot().content_identity + == captured.snapshot.content_identity }); let publication = self .publication @@ -2445,34 +2475,52 @@ impl CodeIndexWorktreeSchedulerV1 { // only the durable write. Republish it without a second // whole-store extract, isolated graph-off retries otherwise // miss their deadline waiting on a cold parser warmup. - let scope = CodeIndexGenerationScopeV1::for_snapshot(&captured.snapshot); - let mut publication = publication; - let manifest_bytes = publication - .publish_atomically(&scope, None, &pending) - .map_err(CodeIndexProductionErrorV1::Publication)?; - CodeIndexPublishedBuildV1::new(pending, manifest_bytes)? + self.commit_pending_publication(pending, control.clone())? } else { + let _worker_memory = self.reserve_incremental_rebuild_memory()?; let mut owner = CodeIndexProductionOwnerV1::new(self.production_config.clone(), publication) .map_err(|error| { CodeIndexSchedulerErrorV1::ProductionOpen(error.to_string()) })? .with_physical_artifact_pool(self.byte_pool.physical_artifacts.clone()); - 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()?, - }, + captured.retained_bytes.clear(); + let result = owner.build_and_publish( + CodeIndexBuildRequestV1 { + resident_memory: Arc::clone(&self.resident_memory), + 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, + ); + let retained_publication = + if let Some((preparation, publication)) = owner.take_unpublished() { + self.publication + .restore_unpublished(UnpublishedCodeIndexPublicationV1 { + preparation, + publication, + }); + true + } else { + false + }; + let requirement = owner.take_reconstruction_requirement(); + result.map_err(|error| { + discarded_construction_refusal( + error, &control, + requirement, + retained_publication, + std::iter::once(&_worker_memory).chain(&captured.retained_reservations), + self.resident_memory.admission_watermark_bytes(), ) - .map_err(|error| prefer_resident_memory_refusal(&control, error.into()))? + })? }; self.require_reusable_publication(&generation)?; self.latest_content_identity = Some(snapshot_content_identity); @@ -3051,8 +3099,14 @@ impl CodeIndexWorktreeSchedulerV1 { let mut source_manifest = SourceContentManifestV1::for_snapshot(&captured.snapshot); let mut reextracted_files = captured.changed_paths.len(); self.freshness_fence.note_successor_build(); + captured.retained_bytes.clear(); + *self.owner.publication_mut() = self + .publication + .clone() + .with_reconcile_publication_fence(Arc::clone(&self.hints), control.clone()); let mut generation = self.owner.build_and_publish( CodeIndexBuildRequestV1 { + resident_memory: Arc::clone(&self.resident_memory), snapshot: captured.snapshot, captured_files: captured.captured_files, changed_files: captured.changed_paths, @@ -3078,8 +3132,10 @@ impl CodeIndexWorktreeSchedulerV1 { snapshot_content_identity = captured.snapshot.content_identity.clone(); source_manifest = SourceContentManifestV1::for_snapshot(&captured.snapshot); reextracted_files = captured.changed_paths.len(); + captured.retained_bytes.clear(); generation = self.owner.build_and_publish( CodeIndexBuildRequestV1 { + resident_memory: Arc::clone(&self.resident_memory), snapshot: captured.snapshot, captured_files: captured.captured_files, changed_files: captured.changed_paths, @@ -3092,10 +3148,34 @@ impl CodeIndexWorktreeSchedulerV1 { &control, ); } + let retained_publication = + if let Some((preparation, publication)) = self.owner.take_unpublished() { + self.publication + .restore_unpublished(UnpublishedCodeIndexPublicationV1 { + preparation, + publication, + }); + true + } else { + false + }; + let requirement = self.owner.take_reconstruction_requirement(); + let generation = generation.map_err(|error| { + discarded_construction_refusal( + error, + &control, + requirement, + retained_publication, + std::iter::once(&_worker_memory).chain(&captured.retained_reservations), + self.resident_memory.admission_watermark_bytes(), + ) + }); let generation = match generation { Ok(generation) => generation, - Err(CodeIndexProductionErrorV1::Interrupted( - tracedecay_code_index::production::CodeIndexInterruptionV1::Cancelled, + Err(CodeIndexSchedulerErrorV1::Production( + CodeIndexProductionErrorV1::Interrupted( + tracedecay_code_index::production::CodeIndexInterruptionV1::Cancelled, + ), )) if retry < MAX_SUPERSEDED_RECONCILE_RETRIES && !self.shutting_down.load(Ordering::Acquire) => { @@ -3103,12 +3183,9 @@ impl CodeIndexWorktreeSchedulerV1 { std::thread::sleep(SUPERSEDED_RECONCILE_RETRY_BACKOFF); continue; } - Err(error) if control.refused_by_resident_memory() => { - return Err(prefer_resident_memory_refusal(&control, error.into())); - } - Err(CodeIndexProductionErrorV1::Input( + Err(CodeIndexSchedulerErrorV1::Production(CodeIndexProductionErrorV1::Input( CodeIndexInputErrorV1::NoExtractableFiles, - )) => { + ))) => { self.latest_content_identity = Some(snapshot_content_identity.clone()); self.mark_reconciled(source_manifest, control.expected_epoch()); return Ok(CodeIndexReconcileOutcomeV1::Noop(CodeIndexNoopEvidenceV1 { @@ -3116,7 +3193,7 @@ impl CodeIndexWorktreeSchedulerV1 { overflow_reconciled, })); } - Err(error) => return Err(error.into()), + Err(error) => return Err(error), }; self.require_reusable_publication(&generation)?; self.latest_content_identity = Some(snapshot_content_identity); @@ -3620,15 +3697,12 @@ impl CodeIndexWorktreeSchedulerV1 { /// Bind one decoded generation to this scheduler's per-generation serving /// derivations, so every reader of the same generation shares one build. - /// The decoded form of `published` for serving: a cold build already - /// holds it, a sparse successor is decoded from the store. + /// Decode the published generation through the canonical resident-memory + /// admission and serving cache, independently of how it was constructed. pub(super) fn decoded_publication( &self, published: &CodeIndexPublishedBuildV1, ) -> Result, CodeIndexPublicationStoreErrorV1> { - if let Some(generation) = published.decoded() { - return Ok(Arc::clone(generation)); - } let generation_id = &published.manifest().generation_id; self.publication .load_generation(generation_id)? @@ -4418,6 +4492,60 @@ fn resident_memory_refused_reconcile() -> CodeIndexSchedulerErrorV1 { )) } +/// Capture exact replay charges while worker and source guards still exist. +/// Prepared publication failures carry no reconstruction demand: their bytes +/// remain owned and only final commit will be retried. +pub(super) fn discarded_construction_refusal<'r>( + error: CodeIndexProductionErrorV1, + control: &DaemonCodeIndexControlV1, + requirement: Option, + retained_publication: bool, + discarded: impl IntoIterator, + watermark: u64, +) -> CodeIndexSchedulerErrorV1 { + // The publication port reports a retired source fence as CompareAndSwap. + // Recover the attempt's cancellation identity so the scheduler retries a + // superseded seal instead of parking it as a deterministic source failure. + // An actual pointer conflict with a live control keeps its original error. + let error = if matches!( + error, + CodeIndexProductionErrorV1::Publication(CodeIndexPublicationStoreErrorV1::CompareAndSwap) + ) && control.is_cancelled() + { + cancelled_code_index_reconcile() + } else { + error.into() + }; + let error = prefer_resident_memory_refusal(control, error); + if retained_publication { + return match error { + CodeIndexSchedulerErrorV1::Production(CodeIndexProductionErrorV1::Publication( + error, + )) => CodeIndexSchedulerErrorV1::PreparedPublicationRefused { error }, + error => error, + }; + } + if !error.is_resident_memory_refusal() { + return error; + } + let Some(requirement) = requirement else { + return error; + }; + let requirement = if control.refused_by_resident_memory() { + requirement.below_ceiling(watermark) + } else { + requirement + }; + match requirement.including_discarded_reservations(discarded) { + Ok(demand) => CodeIndexSchedulerErrorV1::ReconstructionMemoryRefused { + detail: error.to_string(), + source_epoch: control.expected_epoch(), + demand, + }, + Err(failure) => CodeIndexSchedulerErrorV1::Identity(failure.to_string()), + } +} + /// A checkpoint that stopped for RSS is a capacity refusal. Shutdown and a /// newer source epoch keep the error the caller already observed. fn prefer_resident_memory_refusal( diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs index cf07902d1f..84d664e228 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs @@ -49,6 +49,9 @@ use super::{ }; use tracedecay_runtime_core::native_runtime; use tracedecay_runtime_core::path_safety::canonical_existing_identity; +use tracedecay_runtime_core::resident_memory::{ + ResidentMemoryReconstructionDemandFailureV1, ResidentMemoryReconstructionDemandV1, +}; #[cfg(test)] mod cold_read_wake_tests; @@ -1670,24 +1673,276 @@ struct PendingWakeStateV1 { /// fits it) is its retry through the headroom wake. #[derive(Default)] struct MemoryRefusalRetryV1 { - /// Work is parked on resident memory. A reader's wake cannot help until - /// memory is given back, so readers do not wake the worker meanwhile. - waiting: AtomicBool, + state: Mutex, +} + +#[derive(Default)] +struct MemoryRefusalRetryStateV1 { + waiting: bool, + reconstruction: Option<(u64, ResidentMemoryReconstructionDemandV1)>, } impl MemoryRefusalRetryV1 { fn wait(&self) { - self.waiting.store(true, Ordering::Release); + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .waiting = true; } - /// The work landed, or a memory pass is re-checking; readers may wake the - /// worker again. - fn reset(&self) { - self.waiting.store(false, Ordering::Release); + fn finish_auxiliary_wait(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // A predecessor's text or graph work can finish while a successor's + // discarded construction still awaits its complete requirement. + state.waiting = state.reconstruction.is_some(); + } + + fn finish_reconstruction(&self) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .reconstruction = None; + } + + fn begin_retry(&self) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .waiting = false; } fn waiting(&self) -> bool { - self.waiting.load(Ordering::Acquire) + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .waiting + } + + fn record_memory_refusal( + &self, + epoch: &AtomicU64, + error: &CodeIndexSchedulerErrorV1, + ) -> Result { + match error { + CodeIndexSchedulerErrorV1::ReconstructionMemoryRefused { + demand, + source_epoch, + .. + } => self.arm_reconstruction(epoch, *source_epoch, demand.clone()), + CodeIndexSchedulerErrorV1::PreparedPublicationRefused { .. } => { + self.finish_reconstruction(); + self.wait(); + Ok(false) + } + _ => { + self.wait(); + Ok(false) + } + } + } + + fn arm_reconstruction( + &self, + epoch: &AtomicU64, + expected: u64, + demand: ResidentMemoryReconstructionDemandV1, + ) -> Result { + { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if epoch.load(Ordering::Acquire) != expected { + return Ok(false); + } + let demand = match state.reconstruction.take() { + Some((previous_epoch, previous)) if previous_epoch == expected => { + previous.merge(demand)? + } + _ => demand, + }; + state.waiting = true; + state.reconstruction = Some((expected, demand)); + } + // A fitting release may have arrived before the refusal became + // visible. Arm and evaluate immediately, rather than requiring a new + // sample or a second unrelated owner release. + Ok(self.allow_reconstruction(epoch)) + } + + fn allow_reconstruction(&self, source_epoch: &AtomicU64) -> bool { + let requirement = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let live_epoch = source_epoch.load(Ordering::Acquire); + match state.reconstruction.as_ref() { + Some((expected, _)) if *expected != live_epoch => { + state.reconstruction = None; + state.waiting = false; + None + } + Some((_, requirement)) => Some(requirement.clone()), + None => None, + } + }; + requirement.is_none_or(|requirement| requirement.can_retry()) + } +} + +#[cfg(test)] +mod reconstruction_retry_tests { + use super::*; + use std::num::NonZeroU64; + use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, ResidentMemoryComponentIdV1, ResidentMemoryPressureV1, + }; + + fn bytes(value: u64) -> NonZeroU64 { + NonZeroU64::new(value).unwrap() + } + + #[test] + fn reconstruction_gate_preserves_quiescence_across_unrelated_and_banked_wakes() { + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler( + bytes(100), + Arc::new(|| None), + )); + let memory = Arc::new(ProcessResidentMemoryV1::with_pressure( + bytes(100), + Arc::clone(&pressure), + )); + let component = ResidentMemoryComponentIdV1::new("test-construction").unwrap(); + let mut outside = memory.reserve_process_shared(component, bytes(20)).unwrap(); + let operation = memory.begin_operation(); + let held = operation + .reserve_process_shared(component, bytes(70)) + .unwrap(); + operation + .reserve_process_shared(component, bytes(20)) + .unwrap_err(); + let demand = operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap(); + assert_eq!(demand.required_bytes(), 90); + drop(held); + let epoch = AtomicU64::new(0); + let retry = MemoryRefusalRetryV1::default(); + assert!(!retry.arm_reconstruction(&epoch, 0, demand).unwrap()); + assert!(!retry.allow_reconstruction(&epoch)); + memory.wait_for_headroom(5, 100); + outside.shrink_to(19).unwrap(); + assert!( + !retry.allow_reconstruction(&epoch), + "an unrelated waiter cannot admit reconstruction" + ); + retry.begin_retry(); + assert!( + !retry.allow_reconstruction(&epoch), + "a banked memory permit cannot erase the requirement" + ); + retry.finish_auxiliary_wait(); + assert!( + retry.waiting(), + "finishing predecessor work preserves successor waiting" + ); + assert!( + !retry.allow_reconstruction(&epoch), + "predecessor completion cannot erase successor demand" + ); + drop(outside); + assert!(retry.allow_reconstruction(&epoch)); + retry.finish_reconstruction(); + retry.finish_auxiliary_wait(); + assert!(!retry.waiting()); + } + + #[test] + fn refusal_uses_its_actual_control_epoch_without_erasing_a_newer_demand() { + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler( + bytes(100), + Arc::new(|| None), + )); + let memory = Arc::new(ProcessResidentMemoryV1::with_pressure(bytes(100), pressure)); + let component = ResidentMemoryComponentIdV1::new("test-nested-construction").unwrap(); + let outside = memory.reserve_process_shared(component, bytes(20)).unwrap(); + let operation = memory.begin_operation(); + let held = operation + .reserve_process_shared(component, bytes(70)) + .unwrap(); + operation + .reserve_process_shared(component, bytes(20)) + .unwrap_err(); + let demand = operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap(); + drop(held); + let current = AtomicU64::new(1); + let retry = MemoryRefusalRetryV1::default(); + let old = CodeIndexSchedulerErrorV1::ReconstructionMemoryRefused { + detail: "an older source attempt".to_owned(), + source_epoch: 0, + demand: demand.clone(), + }; + assert!(!retry.record_memory_refusal(¤t, &old).unwrap()); + assert!( + !retry.waiting(), + "retired input cannot install waiting state" + ); + let successor = CodeIndexSchedulerErrorV1::ReconstructionMemoryRefused { + detail: "the actual successor attempt".to_owned(), + source_epoch: 1, + demand, + }; + assert!(!retry.record_memory_refusal(¤t, &successor).unwrap()); + assert!(!retry.record_memory_refusal(¤t, &old).unwrap()); + retry.finish_auxiliary_wait(); + assert!(retry.waiting()); + assert!(!retry.allow_reconstruction(¤t)); + drop(outside); + assert!(retry.allow_reconstruction(¤t)); + } + + #[test] + fn reconstruction_gate_catches_prior_releases_and_never_reinstalls_an_old_source() { + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler( + bytes(100), + Arc::new(|| None), + )); + let memory = Arc::new(ProcessResidentMemoryV1::with_pressure(bytes(100), pressure)); + let component = ResidentMemoryComponentIdV1::new("test-construction-race").unwrap(); + let outside = memory.reserve_process_shared(component, bytes(20)).unwrap(); + let operation = memory.begin_operation(); + let held = operation + .reserve_process_shared(component, bytes(70)) + .unwrap(); + operation + .reserve_process_shared(component, bytes(20)) + .unwrap_err(); + let demand = operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap(); + drop((held, outside)); + let epoch = AtomicU64::new(0); + let retry = MemoryRefusalRetryV1::default(); + assert!( + retry.arm_reconstruction(&epoch, 0, demand.clone()).unwrap(), + "a release before arming needs no second event" + ); + epoch.store(1, Ordering::Release); + assert!(retry.allow_reconstruction(&epoch)); + assert!(!retry.arm_reconstruction(&epoch, 0, demand).unwrap()); + assert!( + retry.allow_reconstruction(&epoch), + "the retired pass cannot gate new source" + ); } } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/ignored_dependencies.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/ignored_dependencies.rs index 0ffc724837..109a165329 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/ignored_dependencies.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/ignored_dependencies.rs @@ -117,6 +117,20 @@ fn clone_scheduler_error(error: &CodeIndexSchedulerErrorV1) -> CodeIndexSchedule CodeIndexSchedulerErrorV1::IgnoredDependency(error) => { CodeIndexSchedulerErrorV1::IgnoredDependency(error.clone()) } + CodeIndexSchedulerErrorV1::ReconstructionMemoryRefused { + detail, + source_epoch, + demand, + } => CodeIndexSchedulerErrorV1::ReconstructionMemoryRefused { + detail: detail.clone(), + source_epoch: *source_epoch, + demand: demand.clone(), + }, + CodeIndexSchedulerErrorV1::PreparedPublicationRefused { error } => { + CodeIndexSchedulerErrorV1::PreparedPublicationRefused { + error: error.clone(), + } + } CodeIndexSchedulerErrorV1::WorkerMemoryAdmission(error) => { CodeIndexSchedulerErrorV1::WorkerMemoryAdmission(*error) } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs index 05f3eb4bad..8c2921d22d 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs @@ -911,6 +911,7 @@ impl CodeIndexSchedulerRegistryV1 { > worker_owner_headroom_seen || worker_admission_headroom.has_changed(); if headroom_moved + && worker_memory_retry.allow_reconstruction(&worker_control_epoch) && worktree_waits_for_memory( &worker_text_generation, &worker_convergence_park, @@ -1029,6 +1030,9 @@ impl CodeIndexSchedulerRegistryV1 { // SQL writer alone. let pass_wake_observed_at = Instant::now(); let pass_control_epoch = worker_control_epoch.load(Ordering::Acquire); + if !worker_memory_retry.allow_reconstruction(&worker_control_epoch) { + continue; + } // A quarantined or backing-off unit must not consume the // pending arrival: the wake stays outstanding so a later // eligible pass still measures its full queue wait. @@ -1473,7 +1477,7 @@ impl CodeIndexSchedulerRegistryV1 { .clone(); let memory_pass = trigger == CodeIndexCadenceTriggerV1::MemoryHeadroom; if memory_pass { - worker_memory_retry.reset(); + worker_memory_retry.begin_retry(); } // Memory given back is the retry for a graph refused at the // watermark: its generation activates again on this pass. @@ -1648,6 +1652,16 @@ impl CodeIndexSchedulerRegistryV1 { tracing::trace_span!("daemon.code_index.reconcile_or_seal"), ) .await; + // This result belongs to the source attempt that just ended. + // Retire its construction gate before any nested successor can + // install a new one while this predecessor finishes text work. + if matches!( + &source_result, + Ok(Ok(_) | Err(CodeIndexSchedulerErrorV1::PreparedPublicationRefused { .. })) + ) { + worker_memory_retry.finish_reconstruction(); + } + let mut nested_memory_refusal = None; if worker_shutting_down.load(Ordering::Acquire) { tracing::info!( event = "code_index_worker_shutdown_observed", @@ -2404,7 +2418,7 @@ impl CodeIndexSchedulerRegistryV1 { && !worker_complete_generation_requested.load(Ordering::Acquire); if defer_serving_decode { clear_graph_resident_memory_park(&worker_convergence_park); - worker_memory_retry.reset(); + worker_memory_retry.finish_auxiliary_wait(); Self::release_superseded_serving_seat( &worker_serving_generation, &worker_serving_generation_epoch, @@ -2712,7 +2726,7 @@ impl CodeIndexSchedulerRegistryV1 { seat_retry_backoff = ACTIVATION_RETRY_BACKOFF_FLOOR; last_seat_conflict = None; clear_graph_resident_memory_park(&worker_convergence_park); - worker_memory_retry.reset(); + worker_memory_retry.finish_auxiliary_wait(); } Err(error) if error.is_graph_activation_refusal() => { next_seat_attempt_at = None; @@ -2827,6 +2841,7 @@ impl CodeIndexSchedulerRegistryV1 { let joined = loop { if !worker_shutting_down.load(Ordering::Acquire) && sealed_successor.is_none() + && worker_memory_retry.allow_reconstruction(&worker_control_epoch) && worker_pending_wake.has_pending_arrival() && let Some(metadata) = graph_text.as_ref().map(|text| text.metadata().clone()) @@ -2870,14 +2885,40 @@ impl CodeIndexSchedulerRegistryV1 { "code_index_successor_sealed_during_text_projection", "an arrival during text projection sealed its successor" ); + worker_memory_retry.finish_reconstruction(); + nested_memory_refusal = None; sealed_successor = Some((outcome, arrival, trigger)); } Ok(Ok(_)) => {} - Ok(Err(error)) => tracing::debug!( - event = "code_index_successor_seal_during_text_projection_deferred", - error = %error, - "the successor seal waits for the next pass" - ), + Ok(Err(error)) => { + tracing::debug!( + event = "code_index_successor_seal_during_text_projection_deferred", + error = %error, + "the successor seal waits for the next pass" + ); + if error.is_resident_memory_refusal() { + match worker_memory_retry + .record_memory_refusal(&worker_control_epoch, &error) + { + Ok(true) => Self::note_wake( + &worker_pending_wake, + &worker_wake, + CodeIndexCadenceTriggerV1::MemoryHeadroom, + ), + Ok(false) => {} + Err(failure) => tracing::error!(error = %failure, + "the successor construction memory authority changed"), + } + park_convergence( + &worker_convergence_park, + error.to_string(), + CONVERGENCE_PARK_REFRESH_RESIDENT_MEMORY_REMEDIATION_V1, + Some(CodeIndexBuildBlockedReasonV1::ResidentMemory), + true, + ); + nested_memory_refusal = Some(error); + } + } Err(error) => tracing::warn!( event = "code_index_successor_seal_during_text_projection_task_failed", error = %error, @@ -2941,7 +2982,7 @@ impl CodeIndexSchedulerRegistryV1 { other => other, }; if matches!(outcome, PublishedTextProjectionOutcomeV1::Finished) { - worker_memory_retry.reset(); + worker_memory_retry.finish_auxiliary_wait(); } match outcome { PublishedTextProjectionOutcomeV1::Finished => { @@ -2983,6 +3024,7 @@ impl CodeIndexSchedulerRegistryV1 { // proved the move; re-reconciling this generation // would race that seal. if sealed_successor.is_none() + && worker_memory_retry.allow_reconstruction(&worker_control_epoch) && let Some(text) = graph_text.as_ref() { let proof_unmoved = worker_source_freshness.serves_verified_source( @@ -3040,6 +3082,8 @@ impl CodeIndexSchedulerRegistryV1 { // first seal lost the store lock. Its serving // owners still need the normal publication pass; // reducing Published to a bool loses that work. + worker_memory_retry.finish_reconstruction(); + nested_memory_refusal = None; sealed_successor = Some((outcome, arrival, trigger)); Self::note_worker_continuation( &worker_pending_wake, @@ -3051,11 +3095,29 @@ impl CodeIndexSchedulerRegistryV1 { event = "code_index_post_projection_source_unverified", "source moved while text projection ran; the completed generation may only take a stale seat" ), - Ok(Err(error)) => tracing::warn!( - event = "code_index_post_projection_source_verification_failed", - error = %error, - "source verification after text projection failed; the completed generation may only take a stale seat" - ), + Ok(Err(error)) => { + tracing::warn!( + event = "code_index_post_projection_source_verification_failed", + error = %error, + "source verification after text projection failed; the completed generation may only take a stale seat" + ); + if error.is_resident_memory_refusal() { + match worker_memory_retry.record_memory_refusal( + &worker_control_epoch, + &error, + ) { + Ok(true) => Self::note_wake( + &worker_pending_wake, + &worker_wake, + CodeIndexCadenceTriggerV1::MemoryHeadroom, + ), + Ok(false) => {} + Err(failure) => tracing::error!(error = %failure, + "the successor construction memory authority changed"), + } + nested_memory_refusal = Some(error); + } + } Err(error) => tracing::warn!( event = "code_index_post_projection_source_verification_task_failed", error = %error, @@ -3359,15 +3421,41 @@ impl CodeIndexSchedulerRegistryV1 { drop(reconcile_pass.take()); worker_byte_pool.release_dead_entries(); collect_installed_worker_heaps_when_idle(); - let refused_for_memory = matches!( - &result, - Ok((Err(error), _, _)) if error.is_resident_memory_refusal() - ); + let memory_refusal = nested_memory_refusal.as_ref().or(match &result { + Ok((Err(error), _, _)) => Some(error), + _ => None, + }); + let refused_for_memory = memory_refusal + .is_some_and(CodeIndexSchedulerErrorV1::is_resident_memory_refusal); let waited_for_memory = worker_residency.refresh_waits_for_memory(); worker_residency.set_refresh_waits_for_memory(refused_for_memory); - if let Ok((Err(error), _, _)) = &result + if let Some(error) = memory_refusal && refused_for_memory { + { + match worker_memory_retry + .record_memory_refusal(&worker_control_epoch, error) + { + Ok(true) => Self::note_wake( + &worker_pending_wake, + &worker_wake, + CodeIndexCadenceTriggerV1::MemoryHeadroom, + ), + Ok(false) => {} + Err(failure) => { + park_convergence( + &worker_convergence_park, + failure.to_string(), + "the construction memory authority must be restored", + None, + false, + ); + tracing::error!(event = "code_index_reconstruction_authority_failed", error = %failure, + "construction retry no longer belongs to the mounted memory authority"); + continue; + } + } + } park_convergence( &worker_convergence_park, error.to_string(), @@ -3686,7 +3774,7 @@ impl CodeIndexSchedulerRegistryV1 { } }; if matches!(outcome, PublishedTextProjectionOutcomeV1::Finished) { - worker_memory_retry.reset(); + worker_memory_retry.finish_auxiliary_wait(); worker_serving_generation_changed.send_replace(()); } match outcome { @@ -3929,6 +4017,8 @@ impl CodeIndexSchedulerRegistryV1 { let headroom_text = Arc::downgrade(&text_generation); let headroom_park = Arc::downgrade(&convergence_park); let headroom_residency = Arc::downgrade(&residency); + let headroom_memory_retry = Arc::downgrade(&memory_retry); + let headroom_source_epoch = Arc::downgrade(&epoch); spawn_detached("code_index_headroom_watcher", async move { let Some(cx) = tracedecay_runtime_core::native_runtime::cx() else { tracing::error!( @@ -3968,7 +4058,15 @@ impl CodeIndexSchedulerRegistryV1 { } owner_headroom_seen = by_others; } - if worktree_waits_for_memory(&text, &park, &residency) { + let (Some(memory_retry), Some(source_epoch)) = ( + headroom_memory_retry.upgrade(), + headroom_source_epoch.upgrade(), + ) else { + return; + }; + if memory_retry.allow_reconstruction(&source_epoch) + && worktree_waits_for_memory(&text, &park, &residency) + { Self::note_wake( &pending_wake, &wake, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs index 719d44539f..058a8be976 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs @@ -1703,6 +1703,12 @@ impl LatestCompleteCodeIndexV1 { #[cfg_attr(not(test), allow(dead_code))] pub fn prewarm_serving_derivations(&self) { let _ = self.production_query_owners(); + self.prewarm_graph_derivations(); + } + + /// Graph activation must not advance the independently owned text build. + #[cfg(any(test, feature = "test-helpers"))] + pub(super) fn prewarm_graph_derivations(&self) { let _ = self.record_index(); let generation_id = self.generation.manifest().generation_id.clone(); let Ok(freshness) = self.source_freshness() else { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index cb62507fcc..6100d80c40 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -11,14 +11,15 @@ use std::{ use sha2::{Digest, Sha256}; use tempfile::TempDir; +use tracedecay_application::code_index::DaemonCodeIndexControlV1; use tracedecay_code_index_retention::code_index_generations::{ - CodeGenerationRetentionErrorV1, CodeGenerationRetentionModeV1, - DEFAULT_STRANDED_SCOPE_MINIMUM_AGE_SECS, DurableGenerationIndexEntryV1, - DurablePublicationPointerV1, MAX_CODE_GENERATION_RETENTION_BATCH_V1, - acquire_code_generation_store_lock, code_generation_segments_root, - code_text_artifact_staging_root, code_text_artifacts_root, durable_generation_index_digest, - execute_code_generation_retention_cancellable, execute_scope_root_retention, - plan_scope_root_retention_with_liveness_proof, + CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1, CodeGenerationRetentionErrorV1, + CodeGenerationRetentionModeV1, DEFAULT_STRANDED_SCOPE_MINIMUM_AGE_SECS, + DurableGenerationIndexEntryV1, DurablePublicationPointerV1, + MAX_CODE_GENERATION_RETENTION_BATCH_V1, acquire_code_generation_store_lock, + code_generation_segments_root, code_text_artifact_staging_root, code_text_artifacts_root, + durable_generation_index_digest, execute_code_generation_retention_cancellable, + execute_scope_root_retention, plan_scope_root_retention_with_liveness_proof, prepare_next_code_generation_retention_cancellable, run_code_generation_retention, scope_root_liveness_proof, try_acquire_code_generation_store_read_lock, withdraw_verified_text_artifact_under_lock, @@ -33,11 +34,15 @@ use tracedecay_domain::{ use tracedecay_query::retrieval::lexical::LexicalLaneRequest; use tracedecay_query::retrieval::ports::RetrievalPortError; use tracedecay_runtime_core::resident_memory::{ - ProcessResidentMemoryV1, ProcessResidentSampleV1, ResidentMemoryComponentIdV1, + ProcessResidentMemoryV1, ProcessResidentSampleV1, + RESIDENT_MEMORY_PRESSURE_ADMISSION_FLOOR_BYTES_V1, ResidentMemoryComponentIdV1, ResidentMemoryPressureV1, }; -use super::super::publication_store::ActiveGenerationDecodeChargeV1; +use super::super::PendingHintsV1; +use super::super::publication_store::{ + ActiveGenerationDecodeChargeV1, UnpublishedCodeIndexPublicationV1, +}; use super::{ EIGHT_DAYS_SECS, GitFixture, RETAINED_REVISION_0, downgrade_pointer_to_pre_segment_bytes_shape, execute_proof_bound_scope_retention, published, remove_historical_pointer_entries, @@ -49,15 +54,105 @@ use crate::code_index_scheduler::{ SharedCodeIndexBytePoolV1, scoped_code_index_store_root, }; use tracedecay_code_index::production::{ - CodeGraphBuildBoundV1, CodeIndexAtomicPublicationPort, CodeIndexColdBuildReasonV1, - CodeIndexExecutionControlV1, CodeIndexInterruptionV1, CodeIndexProductionErrorV1, - CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, CodeIndexSealedPublicationV1, + CodeGraphBuildBoundV1, CodeIndexAtomicPublicationPort, CodeIndexExecutionControlV1, + CodeIndexInterruptionV1, CodeIndexPreparationPort, CodeIndexProductionErrorV1, + CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, SEALED_GENERATION_FORMAT_REVISION_V1, SealedGenerationSegmentReadV1, SharedDecodedContentPoolV1, UninterruptibleCodeIndexControlV1, VerifiedSealedLexicalPageReadV1, }; -fn cold(generation: &Arc) -> CodeIndexSealedPublicationV1 { - CodeIndexSealedPublicationV1::Cold(Arc::clone(generation), CodeIndexColdBuildReasonV1::NoParent) +/// Re-publication stages an already validated decoded generation through the +/// same target writer; failures retain the port's exact typed refusal. +fn publish_generation( + target: &mut super::super::DaemonCodeIndexPublicationStoreV1, + generation: &Arc, + expected: Option<&CodeGenerationId>, +) -> Result, CodeIndexPublicationStoreErrorV1> { + let mut preparation = target.prepare(&generation.sealed_scope(), expected)?; + let publication = match generation.stage_publication(&mut preparation) { + Ok(publication) => publication, + Err(error) => { + preparation.abort()?; + return Err(match error { + CodeIndexProductionErrorV1::Publication(error) => error, + error => CodeIndexPublicationStoreErrorV1::Unavailable(error.to_string()), + }); + } + }; + let result = target.commit(&mut preparation, &publication); + if result.is_err() { + preparation.abort()?; + } + result +} + +#[test] +fn a_prepared_publication_retries_without_a_construction_worker_reservation() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_store = TempDir::new().expect("source store"); + let mut source = scheduler( + &fixture, + source_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(source.reconcile_now().expect("source publication")); + let generation = source.latest_complete().expect("decode source").generation; + let target_store = TempDir::new().expect("target store"); + let mut target = scheduler( + &fixture, + target_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + let mut preparation = target + .publication + .prepare(&generation.sealed_scope(), None) + .expect("prepare target"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage target"); + let exact_manifest = Arc::clone(publication.manifest_bytes()); + target + .publication + .restore_unpublished(UnpublishedCodeIndexPublicationV1 { + preparation, + publication, + }); + + let limit = NonZeroU64::new(64 * 1024 * 1024).expect("limit"); + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler( + limit, + Arc::new(|| None), + )); + let memory = Arc::new(ProcessResidentMemoryV1::with_pressure(limit, pressure)); + target.bind_resident_memory(Arc::clone(&memory)); + let standing_bytes = limit.get() - RESIDENT_MEMORY_PRESSURE_ADMISSION_FLOOR_BYTES_V1 + 1; + let standing = memory + .reserve_process_shared( + ResidentMemoryComponentIdV1::new("other-build").expect("component"), + NonZeroU64::new(standing_bytes).expect("standing charge"), + ) + .expect("standing charge fits"); + assert!( + limit.get() - memory.snapshot().used_bytes + < RESIDENT_MEMORY_PRESSURE_ADMISSION_FLOOR_BYTES_V1, + "available admission is smaller than a construction worker request", + ); + let retried = target + .republish_unpublished_retained_generation() + .expect("prepared retry needs only source verification and commit admission") + .expect("prepared publication remains current"); + published(retried); + assert_eq!( + target + .publication + .load_active_manifest() + .expect("active manifest") + .expect("prepared generation is durable") + .manifest_bytes + .as_ref(), + exact_manifest.as_ref(), + ); + assert_eq!(memory.snapshot().used_bytes, standing.reserved_bytes()); } #[test] @@ -596,9 +691,7 @@ fn lazy_lexical_source_cancels_when_retention_retires_its_unread_segments() { Arc::new(SharedCodeIndexBytePoolV1::default()), ); published(scheduler.reconcile_now().expect("publish first generation")); - let latest = scheduler - .latest_complete_already_decoded() - .expect("first generation"); + let latest = scheduler.latest_complete().expect("first generation"); let generation_id = latest.generation.manifest().generation_id.clone(); let text_store = &latest.text.text_artifact_store; let identity = text_store @@ -672,9 +765,7 @@ fn lazy_lexical_source_cancels_when_retention_retires_its_unread_segments() { ); assert_eq!(source.cursor(), &initial_cursor); - let latest = scheduler - .latest_complete_already_decoded() - .expect("successor generation"); + let latest = scheduler.latest_complete().expect("successor generation"); let text_store = &latest.text.text_artifact_store; let identity = text_store .sealed_identity(&latest.generation.manifest().generation_id) @@ -1009,6 +1100,36 @@ fn failed_and_crashed_evidence_pack_temporaries_are_removed() { ); } +#[test] +fn source_supersession_during_sealing_retries_the_reconcile() { + let fixture = GitFixture::new(&[ + ("src/first.rs", "pub fn first() {}\n"), + ("src/second.rs", "pub fn second() {}\n"), + ]); + let store = TempDir::new().expect("publication store"); + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + let epoch = Arc::clone(&scheduler.epoch); + let superseded = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observed = Arc::clone(&superseded); + scheduler.publication = scheduler + .publication + .clone() + .with_segments_dir_sync_observer_for_test(Arc::new(move || { + if !observed.swap(true, std::sync::atomic::Ordering::AcqRel) { + DaemonCodeIndexControlV1::advance(&epoch); + } + })); + + published(scheduler.reconcile_now().expect("superseded seal retries")); + assert!(superseded.load(std::sync::atomic::Ordering::Acquire)); + let active = scheduler.latest_complete().expect("successor is readable"); + assert_eq!(active.generation.snapshot().files.len(), 2); +} + #[test] fn retired_fence_cancels_a_generation_seal_between_segments() { // One segment per file: shutdown is signalled after the first durable @@ -1041,8 +1162,8 @@ fn retired_fence_cancels_a_generation_seal_between_segments() { ); Arc::clone( &scheduler - .latest_complete_already_decoded() - .expect("multi-file generation remains decoded") + .latest_complete() + .expect("multi-file generation decodes") .generation, ) }; @@ -1069,8 +1190,7 @@ fn retired_fence_cancels_a_generation_seal_between_segments() { observer_shutting_down.store(true, std::sync::atomic::Ordering::Release); })); - let error = publication - .publish_atomically(&generation.sealed_scope(), None, &cold(&generation)) + let error = publish_generation(&mut publication, &generation, None) .expect_err("shutdown signalled mid-seal must stop the publication"); assert!( matches!(error, CodeIndexPublicationStoreErrorV1::CompareAndSwap), @@ -1146,8 +1266,8 @@ fn publishing_many_new_segments_syncs_the_segments_directory_once() { ); Arc::clone( &scheduler - .latest_complete_already_decoded() - .expect("multi-file generation remains decoded") + .latest_complete() + .expect("multi-file generation decodes") .generation, ) }; @@ -1175,8 +1295,7 @@ fn publishing_many_new_segments_syncs_the_segments_directory_once() { sync_observer.fetch_add(1, std::sync::atomic::Ordering::AcqRel); })); - publication - .publish_atomically(&generation.sealed_scope(), None, &cold(&generation)) + publish_generation(&mut publication, &generation, None) .expect("publish a fresh multi-segment generation"); let segment_count = published_segments.load(std::sync::atomic::Ordering::Acquire); @@ -1266,8 +1385,7 @@ fn evidence_pack_failure_after_pages_never_publishes_manifest_or_pointer() { std::fs::write(&colliding_pack, b"wrong immutable evidence pack") .expect("seed corrupt immutable evidence target"); - let error = publication - .publish_atomically(&generation.sealed_scope(), None, &cold(&generation)) + let error = publish_generation(&mut publication, &generation, None) .expect_err("a conflicting aggregate pack must fail publication"); assert!( error @@ -1323,8 +1441,7 @@ fn evidence_pack_failure_after_pages_never_publishes_manifest_or_pointer() { .join(&source_generation_file); std::fs::write(&colliding_manifest, b"wrong immutable generation manifest") .expect("seed corrupt immutable generation target"); - let error = publication - .publish_atomically(&generation.sealed_scope(), None, &cold(&generation)) + let error = publish_generation(&mut publication, &generation, None) .expect_err("a post-pack manifest collision must fail publication"); assert!( error @@ -1340,8 +1457,8 @@ fn evidence_pack_failure_after_pages_never_publishes_manifest_or_pointer() { "the failure must occur after the final evidence-pack durability transaction" ); assert!( - !colliding_pack.exists(), - "a pre-manifest publication failure must immediately roll back its final pack" + colliding_pack.exists(), + "canonical packs remain available to peer preparations until ordinary retention" ); assert!( !failed_store @@ -1352,8 +1469,11 @@ fn evidence_pack_failure_after_pages_never_publishes_manifest_or_pointer() { ); std::fs::remove_file(colliding_manifest).expect("remove injected manifest collision"); - std::fs::copy(&source_pack, &colliding_pack) - .expect("simulate a crash after the final evidence-pack rename"); + assert_eq!( + std::fs::read(&colliding_pack).expect("retained canonical pack"), + std::fs::read(&source_pack).expect("source canonical pack"), + "failed manifest publication retains the exact canonical pack" + ); drop(publication); let _reopened = super::super::DaemonCodeIndexPublicationStoreV1::new( failed_store.path(), @@ -2356,8 +2476,8 @@ fn durable_publication_writes_partitioned_manifest_and_reuses_immutable_targets( ); published(scheduler.reconcile_now().expect("initial publish")); let latest = scheduler - .latest_complete_already_decoded() - .expect("published generation remains decoded"); + .latest_complete() + .expect("published generation decodes"); let pointer_path = store.path().join("active-code-generation-v1.json"); let pointer: DurablePublicationPointerV1 = serde_json::from_slice(&std::fs::read(&pointer_path).expect("read active pointer")) @@ -2392,12 +2512,7 @@ fn durable_publication_writes_partitioned_manifest_and_reuses_immutable_targets( .expect("sanitizer revision"), ) .expect("reopen publication store"); - reopened - .publish_atomically( - &latest.generation.sealed_scope(), - Some(&active_generation), - &cold(&latest.generation), - ) + publish_generation(&mut reopened, &latest.generation, Some(&active_generation)) .expect("identical immutable generation republishes"); assert_eq!( reopened @@ -2557,8 +2672,8 @@ fn retired_sealed_manifest_revision_is_rebuilt_and_logged() { ); published(scheduler.reconcile_now().expect("initial publish")); scheduler - .latest_complete_already_decoded() - .expect("published generation remains decoded") + .latest_complete() + .expect("published generation decodes") .generation .manifest() .generation_id @@ -2584,7 +2699,7 @@ fn retired_sealed_manifest_revision_is_rebuilt_and_logged() { ); let rebuilt = reopened - .latest_complete_already_decoded() + .latest_complete() .expect("the rebuilt generation serves"); assert_ne!( rebuilt.generation.manifest().generation_id, @@ -2633,10 +2748,9 @@ fn publication_over_an_undecodable_active_generation_refuses_a_moved_pointer() { ); published(scheduler.reconcile_now().expect("initial publish")); let seeded = scheduler - .latest_complete_already_decoded() - .expect("published generation remains decoded") + .latest_complete() + .expect("published generation decodes") .generation; - let scope = seeded.sealed_scope(); drop(scheduler); let observed = rewrite_active_generation_as_previous_revision(store.path(), true); @@ -2686,8 +2800,7 @@ fn publication_over_an_undecodable_active_generation_refuses_a_moved_pointer() { write_repaired_pointer(&pointer_path, &mut moved); let mut refusing = publication.for_undecoded_active_rebuild(&observed); - let error = refusing - .publish_atomically(&scope, None, &cold(&seeded)) + let error = publish_generation(&mut refusing, &seeded, None) .expect_err("a pointer that moved under the writer must refuse the publication"); assert!( matches!(error, CodeIndexPublicationStoreErrorV1::CompareAndSwap), @@ -2706,8 +2819,7 @@ fn publication_over_an_undecodable_active_generation_refuses_a_moved_pointer() { let mut restored = observed.clone(); write_repaired_pointer(&pointer_path, &mut restored); let mut admitting = publication.for_undecoded_active_rebuild(&observed); - admitting - .publish_atomically(&scope, None, &cold(&seeded)) + publish_generation(&mut admitting, &seeded, None) .expect("the observed identity still admits the rebuild"); } @@ -3570,8 +3682,8 @@ fn a_sweep_never_collects_segments_a_publication_has_not_yet_named() { published(scheduler.reconcile_now().expect("build generation")); Arc::clone( &scheduler - .latest_complete_already_decoded() - .expect("generation remains decoded") + .latest_complete() + .expect("generation decodes") .generation, ) }; @@ -3608,8 +3720,7 @@ fn a_sweep_never_collects_segments_a_publication_has_not_yet_named() { .push(outcome); })); - publication - .publish_atomically(&generation.sealed_scope(), None, &cold(&generation)) + publish_generation(&mut publication, &generation, None) .expect("publish while sibling sweeps run"); let sweeps = sweeps.lock().expect("sweep observations"); @@ -3729,3 +3840,478 @@ fn a_pointer_sealed_before_segment_bytes_is_corrupt_until_the_store_is_reset() { assert_eq!(rebuilt.generation_index.len(), 1); assert!(rebuilt.generation_index[0].segment_bytes > 0); } + +#[test] +fn a_manifest_cannot_activate_another_preparations_private_segments() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_store = TempDir::new().expect("source store"); + let mut source = scheduler( + &fixture, + source_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(source.reconcile_now().expect("source publication")); + let generation = source + .latest_complete() + .expect("decode canonical source") + .generation; + let target_store = TempDir::new().expect("target store"); + let mut target = scheduler( + &fixture, + target_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + let scope = generation.sealed_scope(); + let mut staged = target.publication.prepare(&scope, None).expect("prepare A"); + let publication = generation.stage_publication(&mut staged).expect("stage A"); + let reader = staged.segment_reader(); + let identities = CodeIndexPublishedGenerationV1::partitioned_segment_identities( + publication.manifest_bytes(), + ) + .expect("canonical segment identities"); + assert_ne!(identities, Vec::new()); + let identity = &identities[0]; + let mut before = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut before, + ) + .expect("A reads its staged bytes"); + let incumbent = target + .publication + .read_publication_pointer() + .expect("incumbent"); + assert!(incumbent.is_none()); + let mut empty = target.publication.prepare(&scope, None).expect("prepare B"); + assert!( + target.publication.commit(&mut empty, &publication).is_err(), + "B owns none of A's private files" + ); + empty.abort().expect("abort B"); + assert_eq!( + target + .publication + .read_publication_pointer() + .expect("unchanged incumbent"), + incumbent + ); + target + .publication + .commit(&mut staged, &publication) + .expect("A commits its exact segments"); + drop(staged); + let mut after = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut after, + ) + .expect("committed reader survives preparation drop"); + assert_eq!(after, before); + let decoded = target + .publication + .load_active_shared() + .expect("verify durable generation") + .expect("published generation"); + assert_eq!( + decoded.manifest().generation_id, + generation.manifest().generation_id + ); +} + +#[test] +fn escaped_staged_readers_refuse_after_abort_or_uncommitted_drop() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_store = TempDir::new().expect("source store"); + let mut source = scheduler( + &fixture, + source_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(source.reconcile_now().expect("source publication")); + let generation = source + .latest_complete() + .expect("decode canonical source") + .generation; + let target_store = TempDir::new().expect("target store"); + let mut target = scheduler( + &fixture, + target_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + for explicit_abort in [true, false] { + let mut preparation = target + .publication + .prepare(&generation.sealed_scope(), None) + .expect("prepare"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage"); + let identities = CodeIndexPublishedGenerationV1::partitioned_segment_identities( + publication.manifest_bytes(), + ) + .expect("segment identities"); + let identity = &identities[0]; + let reader = preparation.segment_reader(); + let mut whole = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut whole, + ) + .expect("whole staged segment"); + assert!(whole.len() > 2); + let mut range = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Range { + digest: &identity.digest, + size_bytes: identity.size_bytes, + offset: 1, + length: 2, + }, + &mut range, + ) + .expect("staged range"); + assert_eq!(range, whole[1..3]); + if explicit_abort { + preparation.abort().expect("abort"); + } else { + drop(preparation); + } + assert!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes + }, + &mut whole + ) + .is_err() + ); + assert!( + std::fs::read_dir(code_generation_segments_root(target_store.path())) + .expect("segments root") + .all(|entry| !entry + .expect("entry") + .file_name() + .to_string_lossy() + .starts_with(CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1)) + ); + } +} + +#[test] +fn staged_segment_content_and_extent_are_authenticated_at_commit() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_store = TempDir::new().expect("source store"); + let mut source = scheduler( + &fixture, + source_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(source.reconcile_now().expect("source publication")); + let generation = source + .latest_complete() + .expect("decode canonical source") + .generation; + let target_store = TempDir::new().expect("target store"); + let mut target = scheduler( + &fixture, + target_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + for truncate in [false, true] { + let mut preparation = target + .publication + .prepare(&generation.sealed_scope(), None) + .expect("prepare"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage"); + let identities = CodeIndexPublishedGenerationV1::partitioned_segment_identities( + publication.manifest_bytes(), + ) + .expect("segment identities"); + let identity = &identities[0]; + let directory = std::fs::read_dir(code_generation_segments_root(target_store.path())) + .expect("segments root") + .map(|entry| entry.expect("entry").path()) + .find(|path| { + path.file_name() + .expect("name") + .to_string_lossy() + .starts_with(CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1) + }) + .expect("owned staging directory"); + let path = directory.join(format!( + "segment-{}.json", + sha256_hex_suffix(identity.digest.as_str()).expect("digest") + )); + let mut bytes = std::fs::read(&path).expect("staged bytes"); + if truncate { + bytes.pop(); + } else { + bytes[0] ^= 1; + } + std::fs::write(&path, bytes).expect("corrupt staging"); + assert!(matches!( + target.publication.commit(&mut preparation, &publication), + Err(CodeIndexPublicationStoreErrorV1::CorruptionResetRequired(_)) + )); + preparation.abort().expect("remove corrupt staging"); + assert!( + target + .publication + .read_publication_pointer() + .expect("unchanged pointer") + .is_none() + ); + } +} + +#[test] +fn resident_refusal_at_final_commit_retains_the_exact_publication_for_a_fresh_control() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_store = TempDir::new().expect("source store"); + let mut source = scheduler( + &fixture, + source_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(source.reconcile_now().expect("source publication")); + let generation = source.latest_complete().expect("decode source").generation; + let target_store = TempDir::new().expect("target store"); + let target = scheduler( + &fixture, + target_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + let observed = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let sampled = Arc::clone(&observed); + let limit = NonZeroU64::new(1024 * 1024).expect("resident limit"); + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler( + limit, + Arc::new(move || { + let bytes = sampled.load(std::sync::atomic::Ordering::Acquire); + Some(ProcessResidentSampleV1 { + resident_bytes: bytes, + unreclaimable_bytes: bytes, + swapped_bytes: 0, + cgroup_headroom_bytes: None, + }) + }), + )); + let epoch = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let control = DaemonCodeIndexControlV1::new(Arc::clone(&epoch), Arc::clone(&shutdown)) + .with_resident_memory(Arc::clone(&pressure)); + let hints = Arc::new(std::sync::Mutex::new(PendingHintsV1::default())); + let (paused_tx, paused) = std::sync::mpsc::sync_channel(1); + let (resume, resume_rx) = std::sync::mpsc::sync_channel(1); + let resume_rx = std::sync::Mutex::new(resume_rx); + let mut attempt = target + .publication + .clone() + .with_reconcile_publication_fence(Arc::clone(&hints), control.clone()) + .with_segments_dir_sync_observer_for_test(Arc::new(move || { + paused_tx.send(()).expect("report final commit pause"); + resume_rx + .lock() + .expect("resume receiver") + .recv() + .expect("resume commit"); + })); + let mut preparation = attempt + .prepare(&generation.sealed_scope(), None) + .expect("prepare"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage publication"); + let exact_manifest = Arc::clone(publication.manifest_bytes()); + let identity = CodeIndexPublishedGenerationV1::partitioned_segment_identities(&exact_manifest) + .expect("segment identities") + .into_iter() + .next() + .expect("segment"); + let reader = preparation.segment_reader(); + let encoded_bytes = attempt + .seal_encoded_segment_bytes + .load(std::sync::atomic::Ordering::Acquire); + assert!( + encoded_bytes > 0, + "the retry owns already encoded segment files" + ); + let worker = std::thread::spawn(move || { + let result = attempt.commit(&mut preparation, &publication); + ( + result, + UnpublishedCodeIndexPublicationV1 { + preparation, + publication, + }, + ) + }); + paused + .recv_timeout(Duration::from_secs(10)) + .expect("commit paused after segment durability"); + observed.store(limit.get(), std::sync::atomic::Ordering::Release); + pressure.publish_observed_resident_bytes(limit.get()); + resume.send(()).expect("release final commit"); + let (result, pending) = worker.join().expect("commit worker"); + assert!(matches!( + result, + Err(CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused(_)) + )); + assert!(control.refused_by_resident_memory()); + assert!( + target + .publication + .read_publication_pointer() + .expect("incumbent") + .is_none() + ); + let mut bytes = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut bytes, + ) + .expect("memory refusal keeps preparation readable"); + target.publication.restore_unpublished(pending); + let mut pending = target + .publication + .take_unpublished() + .expect("owned retry pair retained"); + assert!(Arc::ptr_eq( + pending.publication.manifest_bytes(), + &exact_manifest + )); + observed.store(0, std::sync::atomic::Ordering::Release); + pressure.publish_observed_resident_bytes(0); + assert!( + control.is_cancelled(), + "the refused control remains latched" + ); + let fresh_control = + DaemonCodeIndexControlV1::new(epoch, shutdown).with_resident_memory(pressure); + let mut refreshed = target + .publication + .clone() + .with_reconcile_publication_fence(hints, fresh_control); + let committed = refreshed + .commit(&mut pending.preparation, &pending.publication) + .expect("fresh feasible control resumes the exact prepared publication"); + assert!(Arc::ptr_eq(&committed, &exact_manifest)); + assert_eq!( + refreshed + .seal_encoded_segment_bytes + .load(std::sync::atomic::Ordering::Acquire), + encoded_bytes + ); + drop(pending); + let mut committed_bytes = Vec::new(); + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut committed_bytes, + ) + .expect("committed reader survives preparation drop"); + assert_eq!(committed_bytes, bytes); + assert_eq!( + refreshed + .load_active_shared() + .expect("decode committed generation") + .expect("active generation") + .manifest() + .generation_id, + generation.manifest().generation_id + ); +} + +#[test] +fn source_supersession_at_final_commit_aborts_the_preparation() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let source_store = TempDir::new().expect("source store"); + let mut source = scheduler( + &fixture, + source_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(source.reconcile_now().expect("source publication")); + let generation = source.latest_complete().expect("decode source").generation; + let target_store = TempDir::new().expect("target store"); + let target = scheduler( + &fixture, + target_store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + let epoch = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let control = DaemonCodeIndexControlV1::new( + Arc::clone(&epoch), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + ); + let mut attempt = target + .publication + .clone() + .with_reconcile_publication_fence( + Arc::new(std::sync::Mutex::new(PendingHintsV1::default())), + control, + ) + .with_segments_dir_sync_observer_for_test(Arc::new(move || { + DaemonCodeIndexControlV1::advance(&epoch); + })); + let mut preparation = attempt + .prepare(&generation.sealed_scope(), None) + .expect("prepare"); + let publication = generation + .stage_publication(&mut preparation) + .expect("stage publication"); + let identity = CodeIndexPublishedGenerationV1::partitioned_segment_identities( + publication.manifest_bytes(), + ) + .expect("segment identities") + .into_iter() + .next() + .expect("segment"); + let reader = preparation.segment_reader(); + assert!(matches!( + attempt.commit(&mut preparation, &publication), + Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap) + )); + assert!( + attempt + .read_publication_pointer() + .expect("incumbent") + .is_none() + ); + assert!( + reader( + SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }, + &mut Vec::new() + ) + .is_err() + ); + assert!( + std::fs::read_dir(code_generation_segments_root(target_store.path())) + .expect("segments root") + .all(|entry| !entry + .expect("entry") + .file_name() + .to_string_lossy() + .starts_with(CODE_INDEX_PREPARATION_DIRECTORY_PREFIX_V1)) + ); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs index 875d53625d..f81efcc58b 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/residency.rs @@ -1,26 +1,32 @@ +use std::fmt::Write as _; use std::num::NonZeroU64; use std::path::Path; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use std::time::{Duration, Instant}; use tempfile::TempDir; +use tracedecay_code_index::parallelism::CodeIndexWorkerRuntimeV1; use tracedecay_contracts::code_index_freshness::{ CodeGraphServingReadinessV1, CodeIndexBuildBlockedReasonV1, CodeIndexStalenessStateV1, }; +use tracedecay_domain::configuration::CodeIndexWorkerSelectionV1; use tracedecay_domain::{CodeGenerationId, ProjectId, WorktreeId}; use tracedecay_runtime_core::path_safety::canonical_existing_identity; use tracedecay_runtime_core::resident_memory::{ ProcessResidentMemoryV1, ProcessResidentSampleV1, ProcessSharedMemoryReservationV1, - ResidentHoldingV1, ResidentMemoryComponentIdV1, ResidentMemoryPressureV1, ResidentOwnerBytesV1, - ResidentOwnerKindV1, ResidentOwnerReleaseCauseV1, ResidentOwnerReleaseV1, - ResidentOwnerSampleV1, ResidentOwnerScopeV1, ResidentOwnerV1, ResidentOwnersReportV1, - ResidentOwnersV1, + ResidentHoldingV1, ResidentMemoryComponentIdV1, ResidentMemoryKeyV1, ResidentMemoryPressureV1, + ResidentMemoryReservationV1, ResidentOwnerBytesV1, ResidentOwnerKindV1, + ResidentOwnerReleaseCauseV1, ResidentOwnerReleaseV1, ResidentOwnerSampleV1, + ResidentOwnerScopeV1, ResidentOwnerV1, ResidentOwnersReportV1, ResidentOwnersV1, }; use super::super::{ CodeIndexCadenceTelemetryV1, CodeIndexCadenceTriggerV1, CodeIndexWorkerPhaseV1, }; +use super::reconcile::assert_exact_and_lexical_retrieval; + use super::{ ALPHA_LIB_V1, CodeIndexSchedulerRegistryV1, GitFixture, SERVING_SEAT_FAILURE_CEILING, core_search_request, git, install_verified_graph_store_on_text, @@ -1427,6 +1433,679 @@ async fn a_refused_text_build_retries_when_a_reservation_it_waited_on_drops() { registry.shutdown().await; } +async fn owner_passes_started(registry: &CodeIndexSchedulerRegistryV1, root: &Path) -> u64 { + registry + .subscribe_owner_activity(root) + .await + .expect("mounted worktree activity") + .passes() + .started +} + +/// Mount with an already planned executor while admission is held, so the +/// standing charges exercise build admission rather than runtime planning. +async fn mount_cold_build_with_workers( + registry: &CodeIndexSchedulerRegistryV1, + fixture: &GitFixture, + store: &TempDir, + workers: u16, +) { + let admission = registry.background_reconcile_admission(); + let permits = u32::try_from(admission.available_permits()).expect("admission permit count"); + assert!(permits > 0, "the fresh registry has background admission"); + let _held = admission + .acquire_many(permits) + .await + .expect("hold all background admission"); + registry + .mount_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + ) + .await + .expect("mount scheduler"); + let runtime = CodeIndexWorkerRuntimeV1::build( + CodeIndexWorkerSelectionV1::Exact { workers }, + usize::from(workers), + registry.resident_memory.snapshot().limit_bytes, + ) + .expect("plan the owned executor before admitting the build"); + let mounted = registry.mounted.lock().await; + mounted + .get(fixture.path()) + .expect("mounted worktree") + .scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bind_worker_runtime(runtime); +} + +async fn wait_for_cold_build_memory_park(registry: &CodeIndexSchedulerRegistryV1, root: &Path) { + super::wait_for_owner( + registry, + root, + SERVING_SEAT_FAILURE_CEILING, + "a cold build parked for resident memory", + || async { + registry + .dashboard_freshness(root) + .await + .and_then(|freshness| freshness.parked) + .and_then(|parked| parked.blocked_reason) + .filter(|reason| *reason == CodeIndexBuildBlockedReasonV1::ResidentMemory) + }, + ) + .await; + wait_for_worker_phase(registry, root, CodeIndexWorkerPhaseV1::Parked).await; + // The source arrival stays pending while memory gates reconstruction. + // Quiescence means the pass finished, not that its unpaid arrival vanished. + super::wait_for_quiescent_owner_pass(registry, root).await; +} + +async fn assert_cold_build_stays_parked(registry: &CodeIndexSchedulerRegistryV1, root: &Path) { + let refused = owner_passes_started(registry, root).await; + tokio::time::sleep(Duration::from_secs(2)).await; + assert_eq!( + owner_passes_started(registry, root).await, + refused, + "a memory-parked build must stay quiescent until its full requirement fits" + ); + assert_eq!( + registry + .dashboard_freshness(root) + .await + .and_then(|freshness| freshness.parked) + .and_then(|parked| parked.blocked_reason), + Some(CodeIndexBuildBlockedReasonV1::ResidentMemory) + ); + assert!( + registry.latest_text_serving_for_root(root).await.is_none(), + "a refused cold build must not publish a serving text owner" + ); + assert!( + registry.latest_generation_id(root).await.is_none(), + "a refused cold build must not publish a durable generation" + ); +} + +fn cold_build_worker_bytes(memory: &ProcessResidentMemoryV1) -> u64 { + let component = ResidentMemoryComponentIdV1::new( + super::super::reconcile::CODE_INDEX_WORKER_RESIDENT_COMPONENT_V1, + ) + .unwrap(); + memory + .snapshot() + .charges + .iter() + .filter(|charge| charge.key.component == component) + .map(|charge| charge.bytes) + .sum() +} + +/// Real retained construction outgrows the room beside one installed worker. +/// Its release cannot admit reconstruction, even through an unrelated wake. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_cold_build_refused_during_construction_stays_parked_until_reconstruction_fits() { + const MIB: u64 = 1024 * 1024; + const GIB: u64 = 1024 * MIB; + let files = (0..160) + .map(|file| { + let mut source = String::new(); + for function in 0..80 { + writeln!( + source, + "pub fn charged_{file:03}_{function:03}() -> u32 {{ {function} }}" + ) + .expect("write fixture source"); + } + (format!("src/charged_{file:03}.rs"), source) + }) + .collect::>(); + let borrowed = files + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect::>(); + let fixture = GitFixture::new(&borrowed); + let store = TempDir::new().expect("store root"); + let limit = NonZeroU64::new(16 * GIB).unwrap(); + let ledger = Arc::new(OnceLock::>::new()); + let construction_peak = Arc::new(AtomicU64::new(0)); + let worker_peak = Arc::new(AtomicU64::new(0)); + let construction = ResidentMemoryComponentIdV1::new("code_index.construction.v1").unwrap(); + let sampler = { + let ledger = Arc::clone(&ledger); + let construction_peak = Arc::clone(&construction_peak); + let worker_peak = Arc::clone(&worker_peak); + Arc::new(move || { + if let Some(memory) = ledger.get().and_then(Weak::upgrade) { + construction_peak.fetch_max( + memory.snapshot().process_shared_charge_for(construction), + Ordering::AcqRel, + ); + worker_peak.fetch_max(cold_build_worker_bytes(&memory), Ordering::AcqRel); + } + // Observe real ledger charges without supplying an RSS estimate. + None + }) + }; + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler(limit, sampler)); + let memory = Arc::new(ProcessResidentMemoryV1::with_pressure( + limit, + Arc::clone(&pressure), + )); + ledger.set(Arc::downgrade(&memory)).unwrap(); + let mut standing = memory + .reserve_process_shared( + ResidentMemoryComponentIdV1::new("test-standing-memory").unwrap(), + NonZeroU64::new(limit.get() - 130 * MIB).unwrap(), + ) + .expect("standing charge leaves 130 MiB"); + let foreign = memory + .reserve_process_shared( + ResidentMemoryComponentIdV1::new("test-unrelated-byte").unwrap(), + NonZeroU64::MIN, + ) + .expect("unrelated byte fits"); + let registry = CodeIndexSchedulerRegistryV1::with_resident_memory(1, Arc::clone(&memory)) + .with_resident_owners(Arc::new(ResidentOwnersV1::new(IDLE_WINDOW))); + mount_cold_build_with_workers(®istry, &fixture, &store, 1).await; + wait_for_cold_build_memory_park(®istry, fixture.path()).await; + assert_eq!(worker_peak.load(Ordering::Acquire), 128 * MIB); + assert!( + construction_peak.load(Ordering::Acquire) > 0, + "the actual builder must acquire construction bytes before refusing growth" + ); + assert_eq!(memory.snapshot().process_shared_charge_for(construction), 0); + assert_eq!(cold_build_worker_bytes(&memory), 0); + assert_eq!(memory.snapshot().used_bytes, standing.reserved_bytes() + 1); + assert_cold_build_stays_parked(®istry, fixture.path()).await; + + let mut headroom = pressure.subscribe_headroom(); + drop(headroom.borrow_and_update()); + memory.wait_for_headroom(1, limit.get()); + drop(foreign); + assert!(headroom.has_changed()); + assert_cold_build_stays_parked(®istry, fixture.path()).await; + + // Leave room for the 1.5 GiB text build below the 14.4 GiB watermark. + standing + .shrink_to(12 * GIB) + .expect("release external standing memory"); + let text = wait_for_queryable_text_generation(®istry, fixture.path()).await; + assert_exact_and_lexical_retrieval(&text, "charged_159_079"); + registry.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_refused_cold_build_recovers_from_a_stale_starting_measurement() { + const GIB: u64 = 1024 * 1024 * 1024; + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn stale_sample_target() {}\n")]); + let store = TempDir::new().expect("store root"); + let limit = NonZeroU64::new(16 * GIB).unwrap(); + let outside = Arc::new(AtomicU64::new(8 * GIB)); + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler(limit, { + let outside = Arc::clone(&outside); + Arc::new(move || { + let bytes = outside.load(Ordering::Acquire); + Some(ProcessResidentSampleV1 { + resident_bytes: bytes, + unreclaimable_bytes: bytes, + swapped_bytes: 0, + cgroup_headroom_bytes: None, + }) + }) + })); + let memory = Arc::new(ProcessResidentMemoryV1::with_pressure( + limit, + Arc::clone(&pressure), + )); + pressure.sample_and_publish(); + outside.store(15 * GIB, Ordering::Release); + let registry = CodeIndexSchedulerRegistryV1::with_resident_memory(1, Arc::clone(&memory)) + .with_resident_owners(Arc::new(ResidentOwnersV1::new(IDLE_WINDOW))); + registry + .mount_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + ) + .await + .expect("mount scheduler"); + wait_for_cold_build_memory_park(®istry, fixture.path()).await; + assert!( + pressure.state().is_over_budget(), + "fresh admission must read 15 GiB" + ); + assert_eq!(memory.snapshot().used_bytes, 0); + assert_cold_build_stays_parked(®istry, fixture.path()).await; + + outside.store(11 * GIB, Ordering::Release); + pressure.sample_and_publish(); + let text = wait_for_queryable_text_generation(®istry, fixture.path()).await; + assert_exact_and_lexical_retrieval(&text, "stale_sample_target"); + registry.shutdown().await; +} + +/// External occupancy starts only after A's worker reservation exists, and +/// persists after A unwinds. Tracked occupancy belongs to another worktree; +/// untracked occupancy has its own atomic lifetime outside the ledger. +async fn cold_build_recovers_after_external_burst(tracked: bool) { + const GIB: u64 = 1024 * 1024 * 1024; + const MIB: u64 = 1024 * 1024; + const BURST: u64 = 4 * GIB + GIB / 2; + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn external_burst_target() {}\n")]); + let store = TempDir::new().expect("store root"); + let limit = NonZeroU64::new(16 * GIB).unwrap(); + let ledger = Arc::new(OnceLock::>::new()); + let triggered = Arc::new(AtomicBool::new(false)); + let burst = Arc::new(AtomicU64::new(0)); + let foreign = Arc::new(Mutex::new(None::)); + let worker_peak = Arc::new(AtomicU64::new(0)); + let foreign_key = ResidentMemoryKeyV1 { + project_id: test_project_id(), + worktree_id: WorktreeId::new("worktree.other").unwrap(), + generation_id: CodeGenerationId::new("generation.v1.other").unwrap(), + component: ResidentMemoryComponentIdV1::new("test-overlapping-build").unwrap(), + }; + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler(limit, { + let ledger = Arc::clone(&ledger); + let triggered = Arc::clone(&triggered); + let burst = Arc::clone(&burst); + let foreign = Arc::clone(&foreign); + let worker_peak = Arc::clone(&worker_peak); + let foreign_key = foreign_key.clone(); + Arc::new(move || { + let workers = ledger.get().and_then(Weak::upgrade).map_or(0, |memory| { + let workers = cold_build_worker_bytes(&memory); + worker_peak.fetch_max(workers, Ordering::AcqRel); + if workers > 0 && !triggered.swap(true, Ordering::AcqRel) { + if tracked { + *foreign.lock().expect("foreign reservation") = Some( + memory + .reserve(foreign_key.clone(), NonZeroU64::new(BURST).unwrap()) + .expect("foreign worktree reserves its own burst"), + ); + } + burst.store(BURST, Ordering::Release); + } + workers + }); + let bytes = 10 * GIB + burst.load(Ordering::Acquire) + workers; + Some(ProcessResidentSampleV1 { + resident_bytes: bytes, + unreclaimable_bytes: bytes, + swapped_bytes: 0, + cgroup_headroom_bytes: None, + }) + }) + })); + let memory = Arc::new(ProcessResidentMemoryV1::with_pressure( + limit, + Arc::clone(&pressure), + )); + ledger.set(Arc::downgrade(&memory)).unwrap(); + pressure.sample_and_publish(); + let registry = CodeIndexSchedulerRegistryV1::with_resident_memory(1, Arc::clone(&memory)) + .with_resident_owners(Arc::new(ResidentOwnersV1::new(IDLE_WINDOW))); + mount_cold_build_with_workers(®istry, &fixture, &store, 2).await; + wait_for_cold_build_memory_park(®istry, fixture.path()).await; + assert!(triggered.load(Ordering::Acquire)); + assert_eq!(worker_peak.load(Ordering::Acquire), 256 * MIB); + assert_eq!(cold_build_worker_bytes(&memory), 0); + assert_eq!(burst.load(Ordering::Acquire), BURST); + assert_eq!( + memory.snapshot().charge_for(&foreign_key), + if tracked { BURST } else { 0 } + ); + assert!(pressure.sample_and_publish().unwrap().1.is_over_budget()); + assert_cold_build_stays_parked(®istry, fixture.path()).await; + + burst.store(0, Ordering::Release); + drop(foreign.lock().expect("foreign reservation").take()); + assert!(!pressure.sample_and_publish().unwrap().1.is_over_budget()); + let text = wait_for_queryable_text_generation(®istry, fixture.path()).await; + assert_exact_and_lexical_retrieval(&text, "external_burst_target"); + registry.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_refused_cold_build_recovers_when_an_overlapping_worktree_releases() { + cold_build_recovers_after_external_burst(true).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_refused_cold_build_recovers_when_untracked_external_memory_releases() { + cold_build_recovers_after_external_burst(false).await; +} + +/// A successor refused inside its predecessor's projection keeps its complete +/// reconstruction requirement after that already-admitted projection finishes. +/// The sealed predecessor stays authoritative until external memory releases. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_nested_successor_memory_refusal_survives_predecessor_projection_completion() { + const MIB: u64 = 1024 * 1024; + const GIB: u64 = 1024 * MIB; + let fixture = GitFixture::new(ALPHA_LIB_V1); + let store = TempDir::new().expect("store root"); + let limit = NonZeroU64::new(16 * GIB).unwrap(); + let ledger = Arc::new(OnceLock::>::new()); + let pool = Arc::new(OnceLock::>::new()); + let clone_baseline = Arc::new(AtomicU64::new(0)); + let external_bytes = Arc::new(AtomicU64::new(1)); + let observing = Arc::new(AtomicBool::new(false)); + let construction_baseline = Arc::new(AtomicU64::new(0)); + let construction_peak = Arc::new(AtomicU64::new(0)); + let worker_peak = Arc::new(AtomicU64::new(0)); + let construction_seen = Arc::new(tokio::sync::Notify::new()); + let construction = ResidentMemoryComponentIdV1::new("code_index.construction.v1").unwrap(); + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler(limit, { + let ledger = Arc::clone(&ledger); + let pool = Arc::clone(&pool); + let clone_baseline = Arc::clone(&clone_baseline); + let external_bytes = Arc::clone(&external_bytes); + let observing = Arc::clone(&observing); + let construction_baseline = Arc::clone(&construction_baseline); + let construction_peak = Arc::clone(&construction_peak); + let worker_peak = Arc::clone(&worker_peak); + let construction_seen = Arc::clone(&construction_seen); + Arc::new(move || { + if observing.load(Ordering::Acquire) + && let Some(memory) = ledger.get().and_then(Weak::upgrade) + { + let bytes = memory.snapshot().process_shared_charge_for(construction); + let baseline = construction_baseline.load(Ordering::Acquire); + if bytes > baseline { + construction_peak.fetch_max(bytes - baseline, Ordering::AcqRel); + if let Some(pool) = pool.get().and_then(Weak::upgrade) { + let stats = pool.physical_artifacts.stats(); + if stats.clone_payloads_computed + stats.clone_payloads_reused + > clone_baseline.load(Ordering::Acquire) + { + external_bytes.store(limit.get(), Ordering::Release); + construction_seen.notify_one(); + } + } + } + worker_peak.fetch_max(cold_build_worker_bytes(&memory), Ordering::AcqRel); + } + let bytes = external_bytes.load(Ordering::Acquire); + Some(ProcessResidentSampleV1 { + resident_bytes: bytes, + unreclaimable_bytes: bytes, + swapped_bytes: 0, + cgroup_headroom_bytes: None, + }) + }) + })); + let memory = Arc::new(ProcessResidentMemoryV1::with_pressure( + limit, + Arc::clone(&pressure), + )); + ledger.set(Arc::downgrade(&memory)).unwrap(); + let registry = CodeIndexSchedulerRegistryV1::with_resident_memory(1, Arc::clone(&memory)) + .with_resident_owners(Arc::new(ResidentOwnersV1::new(IDLE_WINDOW))); + pool.set(Arc::downgrade(®istry.byte_pool)).unwrap(); + let root = canonical_existing_identity(fixture.path()).expect("canonical fixture"); + let (projection_started, release_projection) = + registry.pause_next_opened_published_text_projection(root); + registry + .mount_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + ) + .await + .expect("mount predecessor"); + tokio::time::timeout(SERVING_SEAT_FAILURE_CEILING, projection_started) + .await + .expect("predecessor reaches its text projection") + .expect("predecessor projection gate stays armed"); + let predecessor = registry + .sealed_publication_identity(fixture.path(), None) + .await + .expect("predecessor sealed identity") + .expect("predecessor is sealed before projection") + .generation_id() + .clone(); + let clone_work = || { + let stats = registry.byte_pool.physical_artifacts.stats(); + stats.clone_payloads_computed + stats.clone_payloads_reused + }; + let before_successor = clone_work(); + clone_baseline.store(before_successor, Ordering::Release); + let scheduler = registry + .scheduler_handle(fixture.path()) + .await + .expect("scheduler"); + let worker_bytes = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _workers = scheduler + .ensure_worker_plan() + .expect("installed worker plan"); + tracedecay_code_index::parallelism::worker_reservation_bytes( + tracedecay_code_index::parallelism::indexing_workers(), + ) + }; + let baseline = memory.snapshot().process_shared_charge_for(construction); + construction_baseline.store(baseline, Ordering::Release); + let mut standing = memory + .reserve_process_shared( + ResidentMemoryComponentIdV1::new("test-standing-memory").unwrap(), + NonZeroU64::new(limit.get() - memory.snapshot().used_bytes - worker_bytes - 2 * MIB) + .unwrap(), + ) + .expect("standing charge fits beside the small sealed predecessor"); + let foreign = memory + .reserve_process_shared( + ResidentMemoryComponentIdV1::new("test-unrelated-byte").unwrap(), + NonZeroU64::MIN, + ) + .expect("unrelated byte fits"); + observing.store(true, Ordering::Release); + for file in 0..160 { + let mut source = String::new(); + for function in 0..80 { + writeln!( + source, + "pub fn nested_{file:03}_{function:03}() -> u32 {{ {function} }}" + ) + .expect("write fixture source"); + } + fixture.edit(&format!("src/nested_{file:03}.rs"), &source); + } + fixture.commit_all("add successor sources while predecessor projection is held"); + assert!(matches!( + registry.notify_hook_overflow(fixture.path()).await, + super::super::CodeIndexDemandAdmissionV1::Queued + )); + tokio::time::timeout(SERVING_SEAT_FAILURE_CEILING, construction_seen.notified()) + .await + .expect("the nested successor acquires real construction bytes"); + // This lock follows the actual nested build, whose fresh production owner + // shares the physical pool but does not share the scheduler's parse pool. + let scheduler = registry + .scheduler_handle(fixture.path()) + .await + .expect("scheduler"); + let observed_scheduler = Arc::clone(&scheduler); + let observed_memory = Arc::clone(&memory); + let observed_predecessor = predecessor.clone(); + let worker_wake = tokio::time::timeout( + SERVING_SEAT_FAILURE_CEILING, + tokio::task::spawn_blocking(move || { + let scheduler = observed_scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(cold_build_worker_bytes(&observed_memory), 0); + assert_eq!( + observed_memory + .snapshot() + .process_shared_charge_for(construction), + baseline, + "the refused successor releases its construction" + ); + assert_eq!( + scheduler + .publication + .read_publication_pointer() + .expect("durable pointer") + .expect("sealed predecessor") + .generation_id, + observed_predecessor.as_str(), + "nested refusal must leave the predecessor published" + ); + Arc::clone(&scheduler.wake) + }), + ) + .await + .expect("nested build unwinds") + .expect("observe nested build completion"); + assert_eq!(worker_peak.load(Ordering::Acquire), worker_bytes); + assert!(construction_peak.load(Ordering::Acquire) > 0); + let refused_work = clone_work(); + assert!( + refused_work > before_successor, + "the nested successor performs real artifact work" + ); + + let assert_incumbent = || { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!( + scheduler + .publication + .read_publication_pointer() + .expect("durable pointer") + .expect("sealed predecessor remains published") + .generation_id, + predecessor.as_str(), + "the refused successor cannot replace the sealed incumbent" + ); + }; + let mut headroom = pressure.subscribe_headroom(); + drop(headroom.borrow_and_update()); + memory.wait_for_headroom(1, limit.get()); + drop(foreign); + assert!( + !headroom.has_changed(), + "an unrelated byte release cannot clear the external pressure refusal" + ); + // Deliver and bank plain worker wakes without advancing the source epoch. + worker_wake.notify_one(); + worker_wake.notify_one(); + tokio::time::sleep(Duration::from_secs(2)).await; + assert_eq!( + clone_work(), + refused_work, + "banked wakes must not reconstruct inside projection" + ); + assert!( + registry + .latest_text_serving_for_root(fixture.path()) + .await + .is_none() + ); + assert_incumbent(); + + release_projection + .send(()) + .expect("finish predecessor projection attempt"); + wait_for_cold_build_memory_park(®istry, fixture.path()).await; + assert_incumbent(); + let retained = registry + .retained_text_owner_for_root(fixture.path()) + .await + .expect("the sealed predecessor retains its text owner"); + assert_eq!(retained.metadata().manifest().generation_id, predecessor); + assert_eq!( + retained + .metadata() + .snapshot() + .files + .iter() + .map(|file| file.logical_path.as_str()) + .collect::>(), + ["src/lib.rs"], + "the retained owner still describes the exact predecessor" + ); + assert!(retained.query_owners_are_ready()); + retained + .production_query_owners() + .expect("the admitted predecessor completes"); + assert_eq!( + registry + .latest_text_serving_for_root(fixture.path()) + .await + .expect("predecessor remains ready") + .metadata() + .manifest() + .generation_id, + predecessor, + ); + assert_eq!( + registry + .dashboard_freshness(fixture.path()) + .await + .and_then(|freshness| freshness.parked) + .and_then(|parked| parked.blocked_reason), + Some(CodeIndexBuildBlockedReasonV1::ResidentMemory), + "predecessor completion preserves the successor memory refusal" + ); + assert_eq!( + clone_work(), + refused_work, + "finishing the predecessor must preserve the successor gate" + ); + let foreign = memory + .reserve_process_shared( + ResidentMemoryComponentIdV1::new("test-unrelated-byte").unwrap(), + NonZeroU64::MIN, + ) + .expect("another unrelated byte fits"); + memory.wait_for_headroom(1, limit.get()); + drop(foreign); + worker_wake.notify_one(); + tokio::time::sleep(Duration::from_secs(2)).await; + assert_eq!( + clone_work(), + refused_work, + "a parked successor still requires its complete demand" + ); + assert_incumbent(); + assert!(retained.query_owners_are_ready()); + assert_eq!( + registry + .latest_text_serving_for_root(fixture.path()) + .await + .expect("predecessor remains ready") + .metadata() + .manifest() + .generation_id, + predecessor, + ); + + observing.store(false, Ordering::Release); + external_bytes.store(12 * GIB, Ordering::Release); + pressure.sample_and_publish(); + // Leave room for the 1.5 GiB text build below the 14.4 GiB watermark. + standing + .shrink_to(12 * GIB) + .expect("release external standing memory"); + let successor = + super::wait_for_queryable_text_generation_change(®istry, fixture.path(), &predecessor) + .await; + assert!( + clone_work() > refused_work, + "external release admits actual successor work" + ); + assert_exact_and_lexical_retrieval(&successor, "nested_159_079"); + registry.shutdown().await; +} + /// A worktree serving its first generation whose refresh after a commit the /// ledger refuses, still refused well after the reconcile's bounded capacity /// retries (well under a second in tests) would have been spent. Returns the diff --git a/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs b/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs index 182c11a2ba..dc44db524e 100644 --- a/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs +++ b/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/reconcile.rs @@ -5,7 +5,10 @@ use std::{ fmt::Write as _, num::NonZeroU64, path::{Path, PathBuf}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, time::{Duration, Instant}, }; @@ -23,25 +26,35 @@ use tracedecay_contracts::{ callable_code_operation, }; use tracedecay_domain::{ - CodeGenerationId, CodeSourceOmissionReasonV1, CommitId, OmittedCodeSourceV1, ProjectId, - PublicRetrieverStatus, RefId, RetrieverKind, SensitivityLevelV1, SnapshotFileDispositionV1, - UtcMicros, WorktreeId, + AuthorizationRevision, CodeGenerationId, CodeSourceOmissionReasonV1, CommitId, + ComponentRevision, EphemeralSanitizedQueryViewV1, ExactAdmissionRuleRevision, + FreshnessVectorDigest, OmittedCodeSourceV1, PrincipalId, ProjectId, PublicRetrieverStatus, + QueryNormalizationRevision, RefId, RetrievalBudget, RetrievalRequest, RetrievalScope, + RetrievalSnapshot, RetrieverKind, RetrieverOutcome, SanitizerRevision, ScoreDomainId, + SensitivityLevelV1, SingleRootScopeV1, SnapshotFileDispositionV1, TemporalModeV1, UtcMicros, + VectorWatermark, WorktreeId, +}; +use tracedecay_query::retrieval::{ + exact::{CentralExactAdmissionAuthorityV1, ExactAdmissionAuthority, ExactLaneRequest}, + lexical::LexicalLaneRequest, }; +use tracedecay_runtime_core::logging::FormattedTracingCapture; use tracedecay_runtime_core::resident_memory::{ DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, ProcessResidentMemoryV1, ResidentHoldingV1, - ResidentOwnerBytesV1, ResidentOwnerKindV1, ResidentOwnerReleaseV1, ResidentOwnerSampleV1, - ResidentOwnerScopeV1, ResidentOwnerV1, ResidentOwnersV1, + ResidentMemoryPressureV1, ResidentOwnerBytesV1, ResidentOwnerKindV1, ResidentOwnerReleaseV1, + ResidentOwnerSampleV1, ResidentOwnerScopeV1, ResidentOwnerV1, ResidentOwnersV1, detected_process_resident_memory_limit_v1, sampled_process_resident_bytes_v1, + sampled_process_resident_v1, }; use super::super::memory_tests::ledger_memory; #[cfg(unix)] use super::stage_raw_git_path; use super::{ - ALPHA_LIB_V1, GitFixture, OwnerSignals, RETAINED_REVISION_0, SERVING_SEAT_FAILURE_CEILING, - active_text_artifact_path, advance_pointer_to_unseated_successor, application_context, - clear_pending_wake_until_quiet, committed_capture_corpus_files, core_search_request, git, - git_stdout, hold_scheduler_for_root, mounted_core_query_worktree, + ALPHA_LIB_V1, GitFixture, OwnerSignals, RETAINED_REVISION_0, ReadyRetrievalControlV1, + SERVING_SEAT_FAILURE_CEILING, active_text_artifact_path, advance_pointer_to_unseated_successor, + application_context, clear_pending_wake_until_quiet, committed_capture_corpus_files, + core_search_request, git, git_stdout, hold_scheduler_for_root, mounted_core_query_worktree, mounted_core_query_worktree_with_one_permit, move_git_metadata, published, query_authority, query_meta, quiesced_background_reconcile_admission, replace_scheduler_chunker_revision, replace_scheduler_policy_revision, rewrite_active_rust_extractor_revision, @@ -58,8 +71,8 @@ use crate::code_index_scheduler::{ CodeIndexCadenceOutcomeV1, CodeIndexCadenceTriggerV1, CodeIndexEventToReadyReceiptV1, CodeIndexHintPolicyV1, CodeIndexIgnoredDependencyRequestV1, CodeIndexReconcileAdmissionV1, CodeIndexReconcileOutcomeV1, CodeIndexSchedulerRegistryV1, CodeIndexWorkerPhaseV1, - CodeIndexWorktreeSchedulerV1, GenerationDecodeAdmissionV1, LatestCompleteCodeIndexV1, - RetainedGraphRecoveryPauseV1, SharedCodeIndexBytePoolV1, + CodeIndexWorktreeSchedulerV1, GenerationDecodeAdmissionV1, LatestCodeTextGenerationV1, + LatestCompleteCodeIndexV1, RetainedGraphRecoveryPauseV1, SharedCodeIndexBytePoolV1, classification::{ NonUtf8ClassifiedChangeV1, WorktreeChangeClassV1, WorktreeChangeClassificationV1, }, @@ -1394,14 +1407,126 @@ async fn scheduler_notifications_remain_nonblocking_while_reconcile_is_busy() { registry.shutdown().await; } -/// Full pre-seat memory journey: a fresh scheduler decodes the retained active -/// generation, publishes a one-file increment through the partitioned sealer, -/// and drains that successor into the durable text artifact. The default is -/// intentionally substantial and can be raised to the preserved operator -/// shape without changing the exercised production path. +pub(super) fn assert_exact_and_lexical_retrieval( + latest: &LatestCodeTextGenerationV1, + needle: &str, +) { + let owners = latest + .production_query_owners() + .expect("published text query owners"); + let metadata = latest.metadata(); + let base = RetrievalRequest { + principal: PrincipalId::new("principal.scale-text").expect("principal"), + scope: RetrievalScope { + privacy_domain: metadata.manifest().privacy_domain.clone(), + root: SingleRootScopeV1 { + 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(format!("sha256:{}", "f".repeat(64))) + .expect("freshness"), + authorization_revision: AuthorizationRevision::new("authorization.scale-text.v1") + .expect("authorization"), + captured_at: UtcMicros(1), + }, + profile_id: "profile.scale-text.v1" + .to_owned() + .try_into() + .expect("profile"), + budget: RetrievalBudget { + max_candidates_per_lane: 8, + max_fused_candidates: 8, + max_hydrated_results: 8, + max_hydration_bytes: 65_536, + deadline_micros: None, + }, + }; + let query_view = EphemeralSanitizedQueryViewV1::sanitize( + needle, + SanitizerRevision::new("sanitizer.scale-text.v1").expect("sanitizer"), + QueryNormalizationRevision::new("normalization.scale-text.v1").expect("normalization"), + ) + .expect("query view"); + let authority = CentralExactAdmissionAuthorityV1::new( + ExactAdmissionRuleRevision::new(tracedecay_query::retrieval::QUERY_EXACT_RULE_REVISION_V1) + .expect("exact rule"), + ); + let exact = owners + .retrieve_exact(&ExactLaneRequest { + control: &ReadyRetrievalControlV1, + literals: authority.parse_literals(&query_view, &base), + path_prefix: None, + generation: metadata.manifest().generation_id.clone(), + budget: base.budget, + base: base.clone(), + query_view: &query_view, + }) + .expect("exact retrieval from the bounded publication"); + let RetrieverOutcome::Complete(exact) = exact else { + panic!("scale exact retrieval must complete"); + }; + assert!( + !exact.candidates.is_empty(), + "exact retrieval must find {needle}" + ); + let lexical = owners + .retrieve_lexical(&LexicalLaneRequest { + query_view: &query_view, + generation: metadata.manifest().generation_id.clone(), + whole_terms: std::borrow::Cow::Owned(vec![needle.to_owned()]), + subtokens: std::borrow::Cow::Owned(vec![needle.to_owned()]), + phrases: std::borrow::Cow::Owned(Vec::new()), + proximities: std::borrow::Cow::Owned(Vec::new()), + field_filters: std::borrow::Cow::Owned(Vec::new()), + path_prefix: None, + fuzzy_budget: 0, + lexical_profile_revision: ComponentRevision::new( + tracedecay_query::retrieval::QUERY_LEXICAL_PROFILE_REVISION_V1, + ) + .expect("lexical profile"), + score_domain: ScoreDomainId::new( + tracedecay_query::retrieval::QUERY_LEXICAL_SCORE_DOMAIN_V1, + ) + .expect("score domain"), + budget: base.budget, + base, + control: &ReadyRetrievalControlV1, + }) + .expect("lexical retrieval from the bounded publication"); + let RetrieverOutcome::Complete(lexical) = lexical else { + panic!("scale lexical retrieval must complete"); + }; + assert!( + !lexical.candidates.is_empty(), + "lexical retrieval must find {needle}" + ); +} + +/// Cold construction, retained activation, a one-file increment, and durable +/// exact/lexical publication under the same real process-memory authority. +/// Full graph decoding is a separate, admitted consumer: ordinary text serving +/// must not materialize the decoded corpus to make this journey ready. #[test] #[ignore = "scale memory regression; run explicitly with one exact test process"] fn retained_decode_incremental_seal_and_text_publish_stay_below_the_high_watermark() { + measure_scale_publication(true); +} + +/// Measure cold construction independently of the text builder's established +/// minimum budget. This journey authenticates the durable sealed publication; +/// the retained journey above additionally requires exact and lexical serving. +#[test] +#[ignore = "scale memory regression; run explicitly with one exact test process"] +fn cold_materialization_publishes_below_the_process_high_watermark() { + measure_scale_publication(false); +} + +fn measure_scale_publication(include_retained_text: bool) { let file_count = std::env::var("TRACEDECAY_PRESEAT_SCALE_FILES").map_or(2_600, |value| { value.parse::().expect("positive scale file count") }); @@ -1437,86 +1562,167 @@ fn retained_decode_incremental_seal_and_text_publish_stay_below_the_high_waterma .map(|(path, source)| (path.as_str(), source.as_str())) .collect::>(); let fixture = GitFixture::new(&sources); + drop(sources); + drop(owned_sources); let store = TempDir::new().expect("store root"); // This scale journey measures real process pressure through admission // as well as its RSS assertions, so retain the production pressure cell. let memory_limit = detected_process_resident_memory_limit_v1(); + let peak_admission = Arc::new(AtomicU64::new(0)); + let peak_process = Arc::new(AtomicU64::new(0)); + let peak_sample = Arc::new(std::sync::Mutex::new( + None::, + )); + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler(memory_limit, { + let admission = Arc::clone(&peak_admission); + let process = Arc::clone(&peak_process); + let peak_sample = Arc::clone(&peak_sample); + Arc::new(move || { + let sample = sampled_process_resident_v1()?; + let mut peak = peak_sample.lock().unwrap(); + if peak.is_none_or(|prior| { + sample.admission_bytes(memory_limit.get()) + >= prior.admission_bytes(memory_limit.get()) + }) { + *peak = Some(sample); + } + drop(peak); + admission.fetch_max( + sample.admission_bytes(memory_limit.get()), + Ordering::Relaxed, + ); + process.fetch_max(sample.resident_bytes, Ordering::Relaxed); + Some(sample) + }) + })); + let memory = Arc::new(ProcessResidentMemoryV1::with_pressure( + memory_limit, + pressure, + )); + let high_watermark = memory.admission_watermark_bytes(); + #[cfg(target_os = "linux")] + std::fs::write("/proc/self/clear_refs", b"5\n") + .expect("reset process peak RSS before cold construction"); + let baseline_rss_bytes = sampled_process_resident_bytes_v1(); + let mut active_decode_count; { let mut seed = scheduler( &fixture, store.path().to_path_buf(), Arc::new(SharedCodeIndexBytePoolV1::default()), ); - seed.bind_resident_memory(Arc::new(ProcessResidentMemoryV1::new(memory_limit))); - published(seed.reconcile_now().expect("seed retained generation")); + seed.bind_resident_memory(Arc::clone(&memory)); + let capture = FormattedTracingCapture::start_with_stderr(); + let result = seed.reconcile_now(); + let captured_trace = capture.text(); + let captured_trace_bytes = captured_trace.len(); + drop(capture); + drop(captured_trace); + let peak = *peak_sample.lock().unwrap(); + println!( + "{}", + serde_json::json!({ + "phase": "cold_construction", + "peak_sample_rss_bytes": peak.map(|sample| sample.resident_bytes), + "peak_sample_unreclaimable_bytes": peak.map(|sample| sample.unreclaimable_bytes), + "peak_sample_swap_bytes": peak.map(|sample| sample.swapped_bytes), + "peak_sample_cgroup_headroom_bytes": peak.and_then(|sample| sample.cgroup_headroom_bytes), + "captured_trace_bytes": captured_trace_bytes, + "files": file_count, + "functions_per_file": functions_per_file, + "source_bytes": source_bytes, + "completed": result.is_ok(), + "error": result.as_ref().err().map(ToString::to_string), + "peak_admission_bytes": peak_admission.load(Ordering::Relaxed), + "high_watermark_bytes": high_watermark, + }) + ); + let evidence = + published(result.expect("cold construction publishes within the configured authority")); + let retained = seed + .publication + .load_active_manifest() + .expect("reopen authenticated cold publication") + .expect("cold publication remains durably active"); + assert_eq!( + retained.metadata.manifest().generation_id, + evidence.generation_id + ); + assert_eq!(retained.metadata.snapshot().files.len(), file_count); + active_decode_count = seed.publication.sealed_decode_count(); + assert_eq!(active_decode_count, 0); } - #[cfg(target_os = "linux")] - std::fs::write("/proc/self/clear_refs", b"5\n").expect("reset process peak RSS"); - let baseline_rss_bytes = sampled_process_resident_bytes_v1(); + if include_retained_text { + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + scheduler.bind_resident_memory(Arc::clone(&memory)); + assert_eq!(scheduler.publication.sealed_decode_count(), 0); + assert!(matches!( + scheduler + .reconcile_now() + .expect("restore retained generation"), + CodeIndexReconcileOutcomeV1::Noop(_) + )); + assert_eq!( + scheduler.publication.sealed_decode_count(), + 0, + "retained activation must preserve the sealed source without decoding the full corpus" + ); - let mut scheduler = scheduler( - &fixture, - store.path().to_path_buf(), - Arc::new(SharedCodeIndexBytePoolV1::default()), - ); - scheduler.bind_resident_memory(Arc::new(ProcessResidentMemoryV1::new(memory_limit))); - assert_eq!(scheduler.publication.sealed_decode_count(), 0); - assert!(matches!( - scheduler - .reconcile_now() - .expect("restore retained generation"), - CodeIndexReconcileOutcomeV1::Noop(_) - )); - assert_eq!( - scheduler.publication.sealed_decode_count(), - 1, - "fresh activation must decode the retained generation exactly once" - ); + let mut edited = String::new(); + for function in 0..functions_per_file { + writeln!( + edited, + "pub fn scale_{:04}_{function:04}() -> usize {{ {} }}", + file_count - 1, + file_count + function + ) + .expect("write edited scale source"); + } + let edited_path = format!("src/scale_{:04}.rs", file_count - 1); + fixture.edit(&edited_path, &edited); + scheduler.notify_path(fixture.path().join(&edited_path)); + let increment = published( + scheduler + .reconcile_now() + .expect("publish one-file incremental generation"), + ); + assert_eq!(increment.reextracted_files, 1); + assert!( + scheduler + .publication + .seal_encoded_segment_bytes + .load(std::sync::atomic::Ordering::Relaxed) + > 0 + ); + assert_eq!( + scheduler + .publication + .seal_existing_segment_bytes_read + .load(std::sync::atomic::Ordering::Relaxed), + 0, + "unchanged parent file segments must stay content-address reused" + ); - let mut edited = String::new(); - for function in 0..functions_per_file { - writeln!( - edited, - "pub fn scale_{:04}_{function:04}() -> usize {{ {} }}", - file_count - 1, - file_count + function - ) - .expect("write edited scale source"); + let latest = scheduler + .servable_retained_text_generation() + .expect("bounded retained text authority") + .expect("incremental generation binds exact and lexical serving"); + while !latest + .advance_text_serving(super::super::TEXT_ARTIFACT_MAXIMUM_WORK_PER_ADVANCE_V1) + .expect("drain successor through durable text publication") + {} + assert!(latest.query_owners_are_ready()); + assert_exact_and_lexical_retrieval(&latest, &format!("scale_{:04}_0000", file_count - 1)); + active_decode_count = scheduler.publication.sealed_decode_count(); + assert_eq!(active_decode_count, 0); } - let edited_path = format!("src/scale_{:04}.rs", file_count - 1); - fixture.edit(&edited_path, &edited); - scheduler.notify_path(fixture.path().join(&edited_path)); - let increment = published( - scheduler - .reconcile_now() - .expect("publish one-file incremental generation"), - ); - assert_eq!(increment.reextracted_files, 1); - assert!( - scheduler - .publication - .seal_encoded_segment_bytes - .load(std::sync::atomic::Ordering::Relaxed) - > 0 - ); - assert_eq!( - scheduler - .publication - .seal_existing_segment_bytes_read - .load(std::sync::atomic::Ordering::Relaxed), - 0, - "unchanged parent file segments must stay content-address reused" - ); - - let latest = scheduler.latest_complete().expect("incremental generation"); - while !latest - .advance_text_serving(super::super::TEXT_ARTIFACT_MAXIMUM_WORK_PER_ADVANCE_V1) - .expect("drain successor through durable text publication") - {} - assert!(latest.query_owners_are_ready()); let settled_rss_bytes = sampled_process_resident_bytes_v1(); #[cfg(target_os = "linux")] let peak_rss_bytes = { @@ -1531,25 +1737,32 @@ fn retained_decode_incremental_seal_and_text_publish_stay_below_the_high_waterma }; #[cfg(not(target_os = "linux"))] let peak_rss_bytes = settled_rss_bytes.unwrap_or(0); - let existing_high_watermark_bytes = 18_u64 - .saturating_mul(1024 * 1024 * 1024) - .saturating_add(84_u64.saturating_mul(1024 * 1024 * 1024) / 100); + assert_eq!(active_decode_count, 0); + let measured_admission = peak_admission.load(Ordering::Relaxed); + assert!( + measured_admission > 0, + "real pressure samples must be observed" + ); println!( "{}", serde_json::json!({ "files": file_count, "functions_per_file": functions_per_file, "source_bytes": source_bytes, - "active_decode_count": scheduler.publication.sealed_decode_count(), + "active_decode_count": active_decode_count, + "retained_text_exercised": include_retained_text, "baseline_rss_bytes": baseline_rss_bytes, "peak_rss_bytes": peak_rss_bytes, "settled_rss_bytes": settled_rss_bytes, - "existing_high_watermark_bytes": existing_high_watermark_bytes, + "configured_limit_bytes": memory_limit.get(), + "high_watermark_bytes": high_watermark, + "peak_admission_bytes": measured_admission, + "peak_sampled_rss_bytes": peak_process.load(Ordering::Relaxed), }) ); assert!( - peak_rss_bytes < existing_high_watermark_bytes, - "pre-seat pipeline peak {peak_rss_bytes} exceeded the existing {existing_high_watermark_bytes}-byte high watermark" + measured_admission < high_watermark, + "the selected publication journey exceeded the canonical admission high watermark: {measured_admission} >= {high_watermark}" ); } @@ -1890,7 +2103,7 @@ fn occurrence_graph_store_is_available_before_catalog_warm() { } /// Linked worktrees sealing identical content share its occurrence identity -/// and physical parse artifacts; each worktree still seals its own snapshot and +/// and decoded content; each worktree still seals its own snapshot and /// generation, and a worktree's generation never names content only its /// sibling holds. #[test] @@ -1926,12 +2139,32 @@ fn linked_worktrees_share_identity_for_identical_content_and_never_serve_diverge .latest_complete() .expect("second generation") .generation; - let reuse = registry.byte_pool_stats(); - - assert!( - reuse.parse_chunk_reused >= 1, - "matching parse/chunk artifacts must be physically shared" + assert_eq!( + first_generation.chunks().chunks().len(), + second_generation.chunks().chunks().len() ); + for (first, second) in first_generation + .chunks() + .chunks() + .iter() + .zip(second_generation.chunks().chunks()) + { + assert!( + Arc::ptr_eq(first, second), + "identical sealed content must share decoded chunks" + ); + } + for (first, second) in first_generation + .symbols() + .symbols + .iter() + .zip(&second_generation.symbols().symbols) + { + assert!( + Arc::ptr_eq(first, second), + "identical sealed content must share decoded symbols" + ); + } assert_eq!(first_publish.repository_id, second_publish.repository_id); assert_eq!( first_publish.file_occurrence_ids, second_publish.file_occurrence_ids, @@ -4370,7 +4603,7 @@ async fn edit_during_text_projection_seals_its_successor_before_the_projection_f release_projection .send(()) .expect("release publication projection"); - tokio::time::timeout(SERVING_SEAT_FAILURE_CEILING, async { + let convergence = tokio::time::timeout(SERVING_SEAT_FAILURE_CEILING, async { loop { let pointer = active_pointer(); let projected = |generation_id: &str| { @@ -4378,18 +4611,49 @@ async fn edit_during_text_projection_seals_its_successor_before_the_projection_f entry.generation_id == generation_id && entry.text_artifact().is_some() }) }; - if projected(&projecting) - && projected(&successor) - && pointer.generation_id != successor - && projected(&pointer.generation_id) - { + // Superseded text owners cancel at their publication fence; + // only the final source generation must finish projecting. + if pointer.generation_id != successor && projected(&pointer.generation_id) { break; } tokio::time::sleep(Duration::from_millis(10)).await; } }) - .await - .expect("both projections finish and the later edit seals and projects its own generation"); + .await; + let final_pointer = active_pointer(); + assert!( + convergence.is_ok(), + "the later edit seals and projects its own generation: projecting={projecting}, successor={successor}, active={}, text_states={:?}", + final_pointer.generation_id, + final_pointer + .generation_index + .iter() + .map(|entry| (&entry.generation_id, entry.text_artifact().is_some())) + .collect::>() + ); + let latest = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .latest_complete() + .expect("explicitly load the final published generation"); + assert_eq!( + latest.generation.manifest().generation_id.as_str(), + final_pointer.generation_id + ); + assert_eq!( + latest + .generation + .symbols() + .symbols + .iter() + .map(|symbol| symbol.simple_name.clone()) + .collect::>(), + BTreeSet::from([ + "alpha".to_owned(), + "edited_during_projection".to_owned(), + "edited_after_seal".to_owned(), + ]) + ); registry.shutdown().await; } @@ -6434,7 +6698,13 @@ fn exact_source_readiness_abstains_when_a_file_is_added_inside_freshness_window( assert!( scheduler .latest_complete_with(GenerationDecodeAdmissionV1::AlreadyDecoded) - .is_some() + .is_none() + ); + + assert_eq!( + scheduler.sealed_decode_count(), + 0, + "source readiness does not require a full decode" ); fixture.edit("src/added.rs", "pub fn added() {}\n"); @@ -11676,6 +11946,7 @@ async fn graph_off_changed_source_advances_text_authority_without_full_decode() .unwrap_or_else(std::sync::PoisonError::into_inner) .as_ref() .expect("generation B remains available for a publication-only retry") + .publication .manifest() .generation_id .clone() diff --git a/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/serving.rs b/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/serving.rs index fc7a641d34..c036fc65da 100644 --- a/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/serving.rs +++ b/crates/tracedecay-code-index-runtime/tests/code_index_scheduler/serving.rs @@ -619,11 +619,15 @@ fn same_process_text_restore_releases_the_decoded_generation_before_projection() Arc::new(SharedCodeIndexBytePoolV1::default()), ); published(scheduler.reconcile_now().expect("seed generation")); - assert!( - scheduler.latest_complete_already_decoded().is_some(), - "seal installs the built generation so reconcile does not encode then decode" - ); + assert!(scheduler.latest_complete_already_decoded().is_none()); assert_eq!(scheduler.sealed_decode_count(), 0); + drop( + scheduler + .latest_complete() + .expect("explicit complete-generation consumer"), + ); + assert!(scheduler.latest_complete_already_decoded().is_some()); + assert_eq!(scheduler.sealed_decode_count(), 1); assert!( scheduler .active_generation_encoded_bytes() @@ -649,7 +653,7 @@ fn same_process_text_restore_releases_the_decoded_generation_before_projection() ); assert_eq!( scheduler.sealed_decode_count(), - 0, + 1, "text restore must not replace the released pin with a sealed-byte decode" ); while !text @@ -661,7 +665,7 @@ fn same_process_text_restore_releases_the_decoded_generation_before_projection() scheduler.latest_complete_already_decoded().is_none(), "ready exact and lexical owners must not re-pin the decoded generation" ); - assert_eq!(scheduler.sealed_decode_count(), 0); + assert_eq!(scheduler.sealed_decode_count(), 1); fixture.edit("src/lib.rs", "pub fn alpha() -> u32 { 2 }\n"); published( @@ -675,7 +679,7 @@ fn same_process_text_restore_releases_the_decoded_generation_before_projection() ); assert_eq!( scheduler.sealed_decode_count(), - 0, + 1, "the next increment seals over its released parent without decoding it" ); let incremental_text = scheduler diff --git a/crates/tracedecay-code-index/benches/code_index_chunks.rs b/crates/tracedecay-code-index/benches/code_index_chunks.rs index 54844bf8ef..45675afc75 100644 --- a/crates/tracedecay-code-index/benches/code_index_chunks.rs +++ b/crates/tracedecay-code-index/benches/code_index_chunks.rs @@ -649,9 +649,10 @@ fn execute_case( // is measurement bookkeeping, not part of the measured sync journey. let wall_ns = u64::try_from(started.elapsed().as_nanos()) .map_err(|_| "sample wall time overflowed u64".to_owned())?; - let output_bytes = serde_json::to_vec(&(current.manifest.chunks(), &changes, handoff.receipt())) - .map_err(|error| format!("serialize {} output evidence: {error}", case.as_str()))? - .len() as u64; + let output_bytes = + serde_json::to_vec(&(current.manifest.chunks(), &changes, handoff.receipt_rows())) + .map_err(|error| format!("serialize {} output evidence: {error}", case.as_str()))? + .len() as u64; let changed_ranges = u64::from(case == CaseName::WarmOneFile); let invalidated_chunks = changes.deleted.len() as u64 + changes diff --git a/crates/tracedecay-code-index/benches/partitioned_codec.rs b/crates/tracedecay-code-index/benches/partitioned_codec.rs index 310d1c5014..12a962fddf 100644 --- a/crates/tracedecay-code-index/benches/partitioned_codec.rs +++ b/crates/tracedecay-code-index/benches/partitioned_codec.rs @@ -7,6 +7,9 @@ use std::{ sync::Arc, time::Instant, }; +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, detected_process_resident_memory_limit_v1, +}; use ignore::WalkBuilder; use serde::Serialize; @@ -263,6 +266,9 @@ fn build_generation( } let content_identity = content_digest(&identity.finalize()); let request = CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot: SanitizedCodeSnapshotV1 { repository: id("repository.partitioned-codec")?, worktree: None, @@ -297,10 +303,14 @@ fn build_generation( MemorySealedPublicationStoreV1::default(), )?; let published = owner.build_and_publish(request, &ActiveControl)?; - let generation = published - .decoded() - .ok_or("a build without a parent runs cold")?; - Ok(Arc::clone(generation)) + let reader = published.sealed_generation().segment_reader(); + Ok(Arc::new( + CodeIndexPublishedGenerationV1::decode_partitioned_sealed( + published.manifest_bytes(), + &SharedDecodedContentPoolV1::default(), + |request, buffer| reader(request, buffer), + )?, + )) } fn production_config() -> Result> { diff --git a/crates/tracedecay-code-index/benches/sealed_storage.rs b/crates/tracedecay-code-index/benches/sealed_storage.rs index 00d962a0fd..f3cc2da231 100644 --- a/crates/tracedecay-code-index/benches/sealed_storage.rs +++ b/crates/tracedecay-code-index/benches/sealed_storage.rs @@ -27,9 +27,12 @@ use std::{ io::{BufRead, BufReader, Read, Write}, path::PathBuf, process::{Command, Stdio}, - sync::Arc, + sync::{Arc, Mutex}, time::Instant, }; +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, detected_process_resident_memory_limit_v1, +}; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -38,21 +41,23 @@ use tracedecay_code_index::{ languages::{LanguageRegistry, StaticLanguageRegistry}, lineage::LineageKindV1, production::{ - CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, CodeIndexExecutionControlV1, + CodeIndexAtomicPublicationPort, CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, + CodeIndexExecutionControlV1, CodeIndexGenerationScopeV1, CodeIndexPreparationPort, CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, - CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, - CodeIndexSealedPublicationV1, MemorySealedPublicationStoreV1, - SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, + CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, + CodeIndexRepositoryParseIdentityV1, CodeIndexSealedGenerationV1, + CodeIndexSealedPublicationV1, MemoryCodeIndexPreparationV1, MemorySealedPublicationStoreV1, + SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, SealedSegmentReaderV1, SharedDecodedContentPoolV1, VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, }, }; use tracedecay_domain::{ - ChunkerRevision, FileOccurrenceId, LanguageId, ManifestDigest, PolicyRevisionId, - PrivacyDomainId, ProjectId, ProjectionKeyV1, ProjectionKindV1, RepositoryDirtyStateV1, - RepositoryId, SanitizationReceiptId, SanitizedCodeFileV1, SanitizedCodeSnapshotV1, - SanitizerRevision, SensitivityLevelV1, SnapshotFileDispositionV1, TreeId, UtcMicros, - WorktreeId, + ChunkerRevision, CodeGenerationId, FileOccurrenceId, LanguageId, ManifestDigest, + PolicyRevisionId, PrivacyDomainId, ProjectId, ProjectionKeyV1, ProjectionKindV1, + RepositoryDirtyStateV1, RepositoryId, SanitizationReceiptId, SanitizedCodeFileV1, + SanitizedCodeSnapshotV1, SanitizerRevision, SensitivityLevelV1, SnapshotFileDispositionV1, + TreeId, UtcMicros, WorktreeId, }; const REPO_ENV: &str = "SEALED_STORAGE_REPO"; @@ -83,6 +88,7 @@ impl CodeIndexExecutionControlV1 for ActiveControl { } } +#[derive(Default)] struct Sealed { manifest: Vec, file_segments: BTreeMap>, @@ -92,6 +98,7 @@ struct Sealed { #[derive(Serialize)] struct GenerationReport { + /// Canonical preparation through commit, including bounded construction. seal_ms: u128, /// Restore through `decode_partitioned_sealed` alone. restore_ms: u128, @@ -149,7 +156,7 @@ fn main() -> Result<(), Box> { let sources = git_sources(&repository, &revision)?; let corpus_bytes = sources.iter().map(|source| source.bytes.len()).sum(); - let store = MemorySealedPublicationStoreV1::default(); + let store = ObservedStoreV1::default(); let mut owner = CodeIndexProductionOwnerV1::new(config()?, store.clone())?; let all_paths = sources .iter() @@ -165,19 +172,20 @@ fn main() -> Result<(), Box> { )?, &ActiveControl, )?; + let (clean_sealed, clean_seal_ms) = store.take_sealed()?; // The same tree sealed again as a linked worktree of the project. - let linked = - CodeIndexProductionOwnerV1::new(config()?, MemorySealedPublicationStoreV1::default())? - .build_and_publish( - request( - &sources, - all_paths, - "tree.sealed-storage.clean", - 2_000_000, - Some(id::("worktree.sealed-storage.linked")?), - )?, - &ActiveControl, - )?; + let linked_store = ObservedStoreV1::default(); + let _linked = CodeIndexProductionOwnerV1::new(config()?, linked_store.clone())? + .build_and_publish( + request( + &sources, + all_paths, + "tree.sealed-storage.clean", + 2_000_000, + Some(id::("worktree.sealed-storage.linked")?), + )?, + &ActiveControl, + )?; let (edited, edited_paths) = edit(&sources); let edited_files = edited_paths.len(); let successor = owner.build_and_publish( @@ -191,13 +199,8 @@ fn main() -> Result<(), Box> { &ActiveControl, )?; - let started = Instant::now(); - let clean_sealed = seal(clean.publication(), None)?; - let clean_seal_ms = started.elapsed().as_millis(); - let started = Instant::now(); - let successor_sealed = seal(successor.publication(), Some(&clean_sealed.manifest))?; - let successor_seal_ms = started.elapsed().as_millis(); - let linked_sealed = seal(linked.publication(), None)?; + let (successor_sealed, successor_seal_ms) = store.take_sealed()?; + let (linked_sealed, _) = linked_store.take_sealed()?; let file_segment_bytes = |segments: &BTreeMap>| segments.values().map(Vec::len).sum::(); let scope_bytes = |sealed: &Sealed| sealed.manifest.len() + sealed.evidence.1.len(); @@ -258,13 +261,13 @@ fn main() -> Result<(), Box> { corpus_bytes, edited_files, clean: generation_report( - &store.decode(clean.manifest_bytes())?, + &store.inner.decode(clean.manifest_bytes())?, &clean_sealed, clean_seal_ms, clean_restore_ms, ), successor: generation_report( - &store.decode(successor.manifest_bytes())?, + &store.inner.decode(successor.manifest_bytes())?, &successor_sealed, successor_seal_ms, successor_restore_ms, @@ -445,6 +448,9 @@ fn request( }); } Ok(CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot: SanitizedCodeSnapshotV1 { repository: id("repository.sealed-storage")?, worktree, @@ -487,57 +493,132 @@ fn config() -> Result> { }) } -fn seal( - publication: &CodeIndexSealedPublicationV1, - parent: Option<&[u8]>, -) -> Result { - let mut file_segments = BTreeMap::new(); - let mut segment_kinds = BTreeMap::new(); - let mut pack = Vec::new(); - let mut evidence = None; - let manifest = publication.encode(parent, |publication| { - match publication { - SealedGenerationSegmentPublicationV1::File { digest, bytes } => { - file_segments.insert(digest.as_str().to_owned(), bytes.to_vec()); - segment_kinds.insert(digest.as_str().to_owned(), "file"); - } - SealedGenerationSegmentPublicationV1::FileEvidence { digest, bytes } => { - file_segments.insert(digest.as_str().to_owned(), bytes.to_vec()); - segment_kinds.insert(digest.as_str().to_owned(), "file_evidence"); - } - SealedGenerationSegmentPublicationV1::ResolutionIndex { digest, bytes } => { - file_segments.insert(digest.as_str().to_owned(), bytes.to_vec()); - segment_kinds.insert(digest.as_str().to_owned(), "resolution_index"); +/// Observe the actual preparation writes so reuse and storage accounting use +/// exactly the segments the production constructor staged. +#[derive(Clone, Default)] +struct ObservedStoreV1 { + inner: MemorySealedPublicationStoreV1, + sealed: Arc>>, +} + +impl ObservedStoreV1 { + fn take_sealed(&self) -> Result<(Sealed, u128), Box> { + self.sealed + .lock() + .map_err(|_| "sealed observation lock poisoned")? + .take() + .ok_or_else(|| "publication observation is missing".into()) + } +} + +struct ObservedPreparationV1 { + inner: MemoryCodeIndexPreparationV1, + sealed: Sealed, + evidence_pack: Vec, + started: Instant, +} + +impl CodeIndexPreparationPort for ObservedPreparationV1 { + fn write_segment( + &mut self, + publication: SealedGenerationSegmentPublicationV1<'_>, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + // The observer retains bytes only for this benchmark's storage report. + match &publication { + SealedGenerationSegmentPublicationV1::File { digest, bytes } + | SealedGenerationSegmentPublicationV1::FileEvidence { digest, bytes } + | SealedGenerationSegmentPublicationV1::ResolutionIndex { digest, bytes } => { + let kind = match &publication { + SealedGenerationSegmentPublicationV1::File { .. } => "file", + SealedGenerationSegmentPublicationV1::FileEvidence { .. } => "file_evidence", + _ => "resolution_index", + }; + self.sealed + .file_segments + .insert(digest.as_str().to_owned(), bytes.to_vec()); + self.sealed + .segment_kinds + .insert(digest.as_str().to_owned(), kind); } SealedGenerationSegmentPublicationV1::CodeGraphPage { page_digest, bytes, .. } => { - file_segments.insert(page_digest.as_str().to_owned(), bytes.to_vec()); - segment_kinds.insert(page_digest.as_str().to_owned(), "code_graph"); + self.sealed + .file_segments + .insert(page_digest.as_str().to_owned(), bytes.to_vec()); + self.sealed + .segment_kinds + .insert(page_digest.as_str().to_owned(), "code_graph"); } SealedGenerationSegmentPublicationV1::GenerationEvidencePage { bytes, .. } => { - pack.extend_from_slice(bytes); + self.evidence_pack.extend_from_slice(bytes); } SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { segment_digest, .. } => { - evidence = Some(( + self.sealed.evidence = ( segment_digest.as_str().to_owned(), - std::mem::take(&mut pack), - )); + std::mem::take(&mut self.evidence_pack), + ); } } - Ok(()) - })?; - Ok(Sealed { - manifest, - file_segments, - segment_kinds, - evidence: evidence.ok_or_else(|| { - CodeIndexProductionErrorV1::Contract("sealed generation has no evidence".to_owned()) - })?, - }) + self.inner.write_segment(publication) + } + + fn segment_reader(&self) -> Arc { + self.inner.segment_reader() + } + + fn abort(self) -> Result<(), CodeIndexPublicationStoreErrorV1> { + self.inner.abort() + } +} + +impl CodeIndexAtomicPublicationPort for ObservedStoreV1 { + type Preparation = ObservedPreparationV1; + + fn load_active( + &self, + scope: &CodeIndexGenerationScopeV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + self.inner.load_active(scope) + } + + fn prepare( + &mut self, + scope: &CodeIndexGenerationScopeV1, + expected: Option<&CodeGenerationId>, + ) -> Result { + let started = Instant::now(); + Ok(ObservedPreparationV1 { + inner: self.inner.prepare(scope, expected)?, + sealed: Sealed::default(), + evidence_pack: Vec::new(), + started, + }) + } + + fn commit( + &mut self, + preparation: &mut Self::Preparation, + publication: &CodeIndexSealedPublicationV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + if preparation.sealed.evidence.0.is_empty() || !preparation.evidence_pack.is_empty() { + return Err(CodeIndexPublicationStoreErrorV1::Unavailable( + "sealed generation has no complete evidence pack".to_owned(), + )); + } + let manifest = self.inner.commit(&mut preparation.inner, publication)?; + preparation.sealed.manifest = manifest.to_vec(); + let seal_ms = preparation.started.elapsed().as_millis(); + *self.sealed.lock().map_err(|_| { + CodeIndexPublicationStoreErrorV1::Unavailable( + "sealed observation lock poisoned".to_owned(), + ) + })? = Some((std::mem::take(&mut preparation.sealed), seal_ms)); + Ok(manifest) + } } /// Restore `sealed` from `segments` (its own plus any it reuses from a diff --git a/crates/tracedecay-code-index/src/production/file_evidence_rows.rs b/crates/tracedecay-code-index/src/production/file_evidence_rows.rs index fbf83ab705..cabe28fec6 100644 --- a/crates/tracedecay-code-index/src/production/file_evidence_rows.rs +++ b/crates/tracedecay-code-index/src/production/file_evidence_rows.rs @@ -29,7 +29,12 @@ use tracedecay_domain::{ CanonicalRelationEdgeV1, CodeGenerationId, EdgeAuthorityV1, FileOccurrenceId, RelationEdgeKindV1, SourceSpan, SymbolIdentityDigest, SymbolOccurrenceId, }; +use tracedecay_runtime_core::resident_memory::ResidentMemoryOperationV1; +use super::resolution_view::ResolutionMemoryChangeV1; +use super::resolution_view::{ + ResolutionFileFactsV1, ResolutionFileV1, reserve_resolution_map, reserve_resolution_vec, +}; use super::{CodeIndexProductionErrorV1, FileGenerationArtifactsV1, collect_bounded_ordered}; use crate::chunks::CodeIndexUnresolvedReferenceV1; use crate::lineage::{LineageKindV1, LineageSymbolRecordV1, SymbolLineageCandidateV1}; @@ -74,16 +79,6 @@ enum PersistedLineageRowV1 { Candidate(Box), } -/// One sealed cross-file edge before its target is bound to a generation's -/// file occurrence. -pub(super) struct SealedCrossFileEdgeV1 { - pub(super) from_occurrence: SymbolOccurrenceId, - pub(super) kind: RelationEdgeKindV1, - pub(super) evidence_span: SourceSpan, - pub(super) target_path: String, - pub(super) target_identity: SymbolIdentityDigest, -} - /// One file's evidence restored onto its generation. #[derive(Debug, Default, PartialEq, Eq)] pub(super) struct FileEvidenceV1 { @@ -99,20 +94,11 @@ struct FileRowsV1<'a> { } impl<'a> FileRowsV1<'a> { - fn of(file: &'a FileGenerationArtifactsV1) -> Self { - let mut symbols = file - .artifacts - .symbols - .iter() - .map(Arc::as_ref) - .collect::>(); - symbols.sort_by(|left, right| left.occurrence.cmp(&right.occurrence)); - let mut references = file - .artifacts - .unresolved_references - .iter() - .collect::>(); - references.sort(); + fn of(file: &'a dyn ResolutionFileFactsV1) -> Self { + let mut symbols = file.symbols().iter().map(Arc::as_ref).collect::>(); + symbols.sort_unstable_by(|left, right| left.occurrence.cmp(&right.occurrence)); + let mut references = file.unresolved_references().iter().collect::>(); + references.sort_unstable(); Self { symbols, references, @@ -130,6 +116,23 @@ impl<'a> FileRowsV1<'a> { } } +/// Borrowed row tables before their allocation-free canonical sort. +fn charge_file_rows( + file: &dyn ResolutionFileFactsV1, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<(), CodeIndexProductionErrorV1> { + charge(ResolutionMemoryChangeV1::Retain( + file.symbols() + .len() + .saturating_mul(std::mem::size_of::<&LineageSymbolRecordV1>()) + .saturating_add( + file.unresolved_references() + .len() + .saturating_mul(std::mem::size_of::<&CodeIndexUnresolvedReferenceV1>()), + ), + )) +} + fn contract(message: &str) -> CodeIndexProductionErrorV1 { CodeIndexProductionErrorV1::Contract(message.to_owned()) } @@ -144,8 +147,8 @@ fn position(index: usize) -> Result { /// Every cross-file edge's source and target, every call limitation's site, /// and every lineage row's current symbol must belong to a file of `files`; /// the seal is refused otherwise. -pub(super) fn compact_file_evidence( - files: &[Arc], +pub(super) fn compact_file_evidence( + files: &[T], edges: &[CanonicalRelationEdgeV1], unresolved_calls: &[CodeIndexUnresolvedReferenceV1], lineage: &[SymbolLineageCandidateV1], @@ -153,8 +156,8 @@ pub(super) fn compact_file_evidence( ) -> Result<(Option, Vec), CodeIndexProductionErrorV1> { let rows = files .iter() - .map(|file| FileRowsV1::of(file)) - .collect::>(); + .map(|file| Ok(FileRowsV1::of(file.binding()?))) + .collect::, CodeIndexProductionErrorV1>>()?; let mut owners = HashMap::<&SymbolOccurrenceId, (usize, u32)>::new(); for (file, file_rows) in rows.iter().enumerate() { for (local, symbol) in file_rows.symbols.iter().enumerate() { @@ -193,7 +196,7 @@ pub(super) fn compact_file_evidence( per_file[file].edges.push(( from, ( - files[target_file].authority.logical_path.as_str(), + files[target_file].logical_path(), &rows[target_file].symbol(target_local)?.identity, ), edge, @@ -228,6 +231,154 @@ pub(super) fn compact_file_evidence( Ok((prior_generation, persisted)) } +/// Compact one file at a time; only borrowed routing rows span files. The +/// visitor receives the live workspace charge so encoding can reserve above +/// it without introducing a second reservation authority. +pub(super) fn for_each_cold_file_evidence( + files: &[T], + edges: &[CanonicalRelationEdgeV1], + calls: &[CodeIndexUnresolvedReferenceV1], + generation: &CodeGenerationId, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, + visit: &mut impl FnMut( + usize, + &PersistedFileEvidenceV1, + u64, + &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<(), CodeIndexProductionErrorV1> { + let mut live = 0_usize; + let mut charge = |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut live, |live| { + retain(u64::try_from(live).map_err(|_| contract("file evidence charge exceeds u64"))?) + }) + }; + let mut owners = HashMap::new(); + for (index, file) in files.iter().enumerate() { + for symbol in file.binding()?.symbols() { + if owners.contains_key(&symbol.occurrence) { + return Err(contract( + "one sealed symbol occurrence belongs to multiple files", + )); + } + reserve_resolution_map(&mut owners, &mut charge)?; + owners.insert(&symbol.occurrence, (index, &symbol.identity)); + } + } + charge(ResolutionMemoryChangeV1::Retain( + files.len().saturating_mul(std::mem::size_of::<( + Vec>, + Vec<&CodeIndexUnresolvedReferenceV1>, + )>()), + ))?; + let mut routed = (0..files.len()) + .map(|_| (Vec::new(), Vec::new())) + .collect::>(); + for edge in edges + .iter() + .filter(|edge| edge.authority == EdgeAuthorityV1::NameResolved) + { + let (owner, _) = owners + .get(&edge.from_occurrence) + .ok_or_else(|| contract("sealed cross-file edge source is not a generation symbol"))?; + let (target, identity) = owners + .get(&edge.to_occurrence) + .ok_or_else(|| contract("sealed cross-file edge target is not a generation symbol"))?; + reserve_resolution_vec(&mut routed[*owner].0, 1, &mut charge)?; + routed[*owner] + .0 + .push((edge, files[*target].logical_path(), *identity)); + } + for call in calls + .iter() + .filter(|call| call.kind != RelationEdgeKindV1::Implements) + { + let (owner, _) = owners + .get(&call.from_occurrence) + .ok_or_else(|| contract("sealed call limitation site is not a generation symbol"))?; + reserve_resolution_vec(&mut routed[*owner].1, 1, &mut charge)?; + routed[*owner].1.push(call); + } + let routing_bytes = live; + for (index, (edges, calls)) in routed.iter().enumerate() { + let facts = files[index].binding()?; + let row_bytes = facts + .symbols() + .len() + .saturating_mul(std::mem::size_of::<&LineageSymbolRecordV1>()) + .saturating_add( + facts + .unresolved_references() + .len() + .saturating_mul(std::mem::size_of::<&CodeIndexUnresolvedReferenceV1>()), + ) + .saturating_add(super::resident_bytes::hash_table_bytes::<( + &SymbolOccurrenceId, + u32, + )>(super::helpers::resolution_hash_capacity( + facts.symbols().len(), + ))) + .saturating_add(edges.len().saturating_mul(std::mem::size_of::<( + u32, + (&str, &SymbolIdentityDigest), + &CanonicalRelationEdgeV1, + )>())) + .saturating_add( + calls + .len() + .saturating_mul(std::mem::size_of::<&CodeIndexUnresolvedReferenceV1>()), + ); + let mut current = routing_bytes.saturating_add(row_bytes); + retain(u64::try_from(current).map_err(|_| contract("file evidence charge exceeds u64"))?)?; + let rows = FileRowsV1::of(facts); + let locals = rows + .symbols + .iter() + .enumerate() + .map(|(local, symbol)| Ok((&symbol.occurrence, position(local)?))) + .collect::, CodeIndexProductionErrorV1>>()?; + let inputs = PerFileInputsV1 { + edges: edges + .iter() + .map(|(edge, path, identity)| { + Ok(( + *locals.get(&edge.from_occurrence).ok_or_else(|| { + contract("sealed cross-file edge source is not a symbol of its file") + })?, + (*path, *identity), + *edge, + )) + }) + .collect::, CodeIndexProductionErrorV1>>()?, + calls: calls.clone(), + lineage: Vec::new(), + }; + let persisted = inputs.compact_charged( + &rows, + None, + generation, + &mut |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut current, |current| { + retain( + u64::try_from(current) + .map_err(|_| contract("file evidence charge exceeds u64"))?, + ) + }) + }, + )?; + visit( + index, + &persisted, + u64::try_from(current).map_err(|_| contract("file evidence charge exceeds u64"))?, + retain, + )?; + } + retain( + u64::try_from(routing_bytes).map_err(|_| contract("file evidence charge exceeds u64"))?, + )?; + Ok(()) +} + /// A cross-file edge from one file with its target's logical path and symbol /// identity. pub(super) type TargetedEdgeV1<'a> = ( @@ -309,27 +460,52 @@ impl PerFileInputsV1<'_> { file_rows: &FileRowsV1<'_>, prior_generation: Option<&CodeGenerationId>, current_generation: &CodeGenerationId, + ) -> Result { + self.compact_charged(file_rows, prior_generation, current_generation, &mut |_| { + Ok(()) + }) + } + + fn compact_charged( + &self, + file_rows: &FileRowsV1<'_>, + prior_generation: Option<&CodeGenerationId>, + current_generation: &CodeGenerationId, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, ) -> Result { let mut persisted = PersistedFileEvidenceV1::default(); let mut paths = HashMap::<&str, u32>::new(); let mut targets = HashMap::<(&str, &SymbolIdentityDigest), u32>::new(); for &(from, (target_path, target_identity), edge) in &self.edges { + if !targets.contains_key(&(target_path, target_identity)) { + reserve_resolution_map(&mut targets, charge)?; + } let target = match targets.entry((target_path, target_identity)) { Entry::Occupied(entry) => *entry.get(), Entry::Vacant(entry) => { + if !paths.contains_key(target_path) { + reserve_resolution_map(&mut paths, charge)?; + } let path = match paths.entry(target_path) { Entry::Occupied(path) => *path.get(), Entry::Vacant(path) => { let at = position(persisted.target_paths.len())?; + reserve_resolution_vec(&mut persisted.target_paths, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain(target_path.len()))?; persisted.target_paths.push(target_path.to_owned()); *path.insert(at) } }; let at = position(persisted.targets.len())?; + reserve_resolution_vec(&mut persisted.targets, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + target_identity.as_str().len(), + ))?; persisted.targets.push((path, target_identity.clone())); *entry.insert(at) } }; + reserve_resolution_vec(&mut persisted.edges, 1, charge)?; persisted.edges.push(( from, target, @@ -342,6 +518,7 @@ impl PerFileInputsV1<'_> { let local = file_rows.references.binary_search(call).map_err(|_| { contract("sealed call limitation is not one of its file's references") })?; + reserve_resolution_vec(&mut persisted.unresolved_calls, 1, charge)?; persisted.unresolved_calls.push(position(local)?); } for &(current, candidate) in &self.lineage { @@ -452,53 +629,71 @@ impl PersistedFileEvidenceV1 { /// The cross-file edges `file` sealed, in sealed order, each still named /// by its target's logical path and symbol identity. - pub(super) fn sealed_edges( + pub(super) fn for_each_sealed_edge( &self, - file: &FileGenerationArtifactsV1, - ) -> Result, CodeIndexProductionErrorV1> { + file: &dyn ResolutionFileFactsV1, + operation: &ResidentMemoryOperationV1, + visit: &mut impl FnMut( + &SymbolOccurrenceId, + RelationEdgeKindV1, + SourceSpan, + &str, + &SymbolIdentityDigest, + ) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result<(), CodeIndexProductionErrorV1> { + let mut memory = super::ConstructionMemoryV1::new(operation.clone())?; + charge_file_rows(file, &mut |change| { + memory.resize(change.apply_to_u64(memory.charged_bytes)?) + })?; let rows = FileRowsV1::of(file); - self.edges - .iter() - .map(|(from, target, kind, start_byte, end_byte)| { - let (target_path, target_identity) = usize::try_from(*target) - .ok() - .and_then(|target| self.targets.get(target)) - .and_then(|(path, identity)| { - let path = self.target_paths.get(usize::try_from(*path).ok()?)?; - Some((path.clone(), identity.clone())) - }) - .ok_or_else(|| contract("sealed cross-file edge names an unknown target"))?; - Ok(SealedCrossFileEdgeV1 { - from_occurrence: rows.symbol(*from)?.occurrence.clone(), - kind: *kind, - evidence_span: SourceSpan { - start_byte: *start_byte, - end_byte: *end_byte, - }, - target_path, - target_identity, + for (from, target, kind, start_byte, end_byte) in &self.edges { + let (target_path, target_identity) = usize::try_from(*target) + .ok() + .and_then(|target| self.targets.get(target)) + .and_then(|(path, identity)| { + let path = self.target_paths.get(usize::try_from(*path).ok()?)?; + Some((path, identity)) }) - }) - .collect() + .ok_or_else(|| contract("sealed cross-file edge names an unknown target"))?; + visit( + &rows.symbol(*from)?.occurrence, + *kind, + SourceSpan { + start_byte: *start_byte, + end_byte: *end_byte, + }, + target_path, + target_identity, + )?; + } + Ok(()) } - /// The call limitations `file` sealed, in sorted order. - pub(super) fn unresolved_calls<'f>( + /// The call limitations `file` sealed, in sorted order. Only borrowed + /// references reach the visitor; the row-table reservation cannot escape. + pub(super) fn for_each_unresolved_call<'f>( &self, - file: &'f FileGenerationArtifactsV1, - ) -> Result, CodeIndexProductionErrorV1> { + file: &'f dyn ResolutionFileFactsV1, + operation: &ResidentMemoryOperationV1, + visit: &mut impl FnMut( + &'f CodeIndexUnresolvedReferenceV1, + ) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result<(), CodeIndexProductionErrorV1> { + let mut memory = super::ConstructionMemoryV1::new(operation.clone())?; + charge_file_rows(file, &mut |change| { + memory.resize(change.apply_to_u64(memory.charged_bytes)?) + })?; let rows = FileRowsV1::of(file); - self.unresolved_calls - .iter() - .map(|call| { - usize::try_from(*call) - .ok() - .and_then(|call| rows.references.get(call).copied()) - .ok_or_else(|| { - contract("sealed call limitation names a reference outside its file") - }) - }) - .collect() + for call in &self.unresolved_calls { + let reference = usize::try_from(*call) + .ok() + .and_then(|call| rows.references.get(call).copied()) + .ok_or_else(|| { + contract("sealed call limitation names a reference outside its file") + })?; + visit(reference)?; + } + Ok(()) } /// The evidence `file` sealed, with edge targets bound to the file each diff --git a/crates/tracedecay-code-index/src/production/file_evidence_tests.rs b/crates/tracedecay-code-index/src/production/file_evidence_tests.rs index a2f131ddad..28e03357af 100644 --- a/crates/tracedecay-code-index/src/production/file_evidence_tests.rs +++ b/crates/tracedecay-code-index/src/production/file_evidence_tests.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use tracedecay_domain::EdgeAuthorityV1; use super::sparse_differential_tests::{FIXTURE_ROOT, fixture_files, language_for, publish}; -use super::worker_tests::{WorkerPublicationStore, worker_config}; +use super::worker_tests::{WorkerPublicationStore, decoded_generation, worker_config}; use super::*; use crate::lineage::LineageKindV1; @@ -19,9 +19,44 @@ struct CountingStoreV1 { inner: WorkerPublicationStore, reads: Arc, read_digests: Arc>>, + writes: Arc>>, +} + +struct CountingPreparationV1 { + inner: MemoryCodeIndexPreparationV1, + writes: Arc>>, +} + +impl CodeIndexPreparationPort for CountingPreparationV1 { + fn write_segment( + &mut self, + publication: SealedGenerationSegmentPublicationV1<'_>, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + let evidence = match &publication { + SealedGenerationSegmentPublicationV1::FileEvidence { digest, bytes } => { + Some(((*digest).clone(), bytes.len())) + } + _ => None, + }; + self.inner.write_segment(publication)?; + if let Some((digest, bytes)) = evidence { + self.writes.lock().expect("write log").insert(digest, bytes); + } + Ok(()) + } + + fn segment_reader(&self) -> Arc { + self.inner.segment_reader() + } + + fn abort(self) -> Result<(), CodeIndexPublicationStoreErrorV1> { + self.inner.abort() + } } impl CodeIndexAtomicPublicationPort for CountingStoreV1 { + type Preparation = CountingPreparationV1; + fn load_active( &self, scope: &CodeIndexGenerationScopeV1, @@ -48,14 +83,25 @@ impl CodeIndexAtomicPublicationPort for CountingStoreV1 { ))) } - fn publish_atomically( + fn prepare( &mut self, scope: &CodeIndexGenerationScopeV1, expected_active_generation: Option<&CodeGenerationId>, - generation: &CodeIndexSealedPublicationV1, + ) -> Result { + let inner = self.inner.prepare(scope, expected_active_generation)?; + self.writes.lock().expect("write log").clear(); + Ok(CountingPreparationV1 { + inner, + writes: Arc::clone(&self.writes), + }) + } + + fn commit( + &mut self, + preparation: &mut Self::Preparation, + publication: &CodeIndexSealedPublicationV1, ) -> Result, CodeIndexPublicationStoreErrorV1> { - self.inner - .publish_atomically(scope, expected_active_generation, generation) + self.inner.commit(&mut preparation.inner, publication) } } @@ -63,18 +109,19 @@ type OwnerV1 = CodeIndexProductionOwnerV1; /// The file evidence segments a sparse successor wrote, by the logical /// path each names. -fn written_evidence(published: &CodeIndexPublishedBuildV1) -> Vec<(String, usize)> { - let CodeIndexSealedPublicationV1::Sparse(sparse) = published.publication() else { - panic!("the edit seals over its parent"); - }; +fn written_evidence( + store: &CountingStoreV1, + published: &CodeIndexPublishedBuildV1, +) -> Vec<(String, usize)> { + assert_eq!( + published.cold_reason(), + None, + "the edit seals over its parent" + ); let generation = super::partitioned_codec::parse_partitioned_manifest(published.manifest_bytes()) .expect("sealed manifest"); - let written = sparse - .written_segments() - .filter(|(kind, _, _)| *kind == "file_evidence") - .map(|(_, digest, bytes)| (digest.clone(), bytes)) - .collect::>(); + let written = store.writes.lock().expect("write log"); generation .file_evidence .iter() @@ -100,7 +147,7 @@ fn sealed_file_evidence_restores_the_built_edges_calls_and_lineage() { let store = WorkerPublicationStore::default(); let mut owner = OwnerV1::new(worker_config(), store.clone()).expect("production owner"); let cold = publish(&mut owner, &tree, None, false, 1_000_000); - let built = cold.decoded().expect("a cold seal holds its generation"); + let built = decoded_generation(&cold); assert!( built .edges @@ -216,8 +263,7 @@ fn an_edit_reads_and_writes_only_what_it_changes() { .expect("the edited leaf"); let base = publish(&mut owner, &base_tree, None, false, 2_000_000); assert_eq!( - base.decoded() - .expect("cold seal") + decoded_generation(&base) .edges .iter() .filter(|edge| edge.authority == EdgeAuthorityV1::NameResolved) @@ -239,7 +285,7 @@ fn an_edit_reads_and_writes_only_what_it_changes() { reads < 24, "an edit to one of {LEAVES} files read {reads} parent segments" ); - let written = written_evidence(&edited); + let written = written_evidence(&store, &edited); assert_eq!( written .iter() diff --git a/crates/tracedecay-code-index/src/production/generation_statistics.rs b/crates/tracedecay-code-index/src/production/generation_statistics.rs index e122bb5d1d..ea025d68ed 100644 --- a/crates/tracedecay-code-index/src/production/generation_statistics.rs +++ b/crates/tracedecay-code-index/src/production/generation_statistics.rs @@ -68,7 +68,7 @@ impl CodeIndexGenerationStatisticsV1 { } } -fn checked_source_total<'a>( +pub(super) fn checked_source_total<'a>( mut coverages: impl Iterator, ) -> Result { coverages.try_fold(0_u64, |total, coverage| { diff --git a/crates/tracedecay-code-index/src/production/go_satisfaction.rs b/crates/tracedecay-code-index/src/production/go_satisfaction.rs index 4cfa475449..8fd67b5dfa 100644 --- a/crates/tracedecay-code-index/src/production/go_satisfaction.rs +++ b/crates/tracedecay-code-index/src/production/go_satisfaction.rs @@ -14,10 +14,11 @@ use tracedecay_domain::{ use crate::chunks::CodeIndexUnresolvedReferenceV1; -use super::FileGenerationArtifactsV1; +use super::CodeIndexProductionErrorV1; use super::helpers::edge_order; use super::module_resolution::ModuleImportIndexV1; -use super::resolution_view::ResolutionFileV1; +use super::resolution_view::ResolutionMemoryChangeV1; +use super::resolution_view::{ResolutionFileFactsV1, ResolutionFileV1}; use super::typescript_resolution::split_parent; #[derive(Default)] @@ -61,11 +62,10 @@ struct GoFileV1<'a> { aliases: HashMap<&'a str, TypeKeyV1<'a>>, } -fn package_scope(file: &FileGenerationArtifactsV1) -> Cow<'_, str> { - let dir = split_parent(file.authority.logical_path.as_str()).0; +fn package_scope(file: &dyn ResolutionFileFactsV1) -> Cow<'_, str> { + let dir = split_parent(file.logical_path()).0; let package = file - .artifacts - .symbols + .symbols() .iter() .find(|symbol| symbol.kind == NodeKind::GoPackage.as_str()) .map(|symbol| symbol.simple_name.as_str()); @@ -167,21 +167,28 @@ struct EffectiveV1<'a> { reason: Option, } -pub(super) fn go_satisfaction( +pub(super) fn go_satisfaction( files: &[T], modules: &ModuleImportIndexV1<'_, T>, -) -> GoSatisfactionV1 -where - T: ResolutionFileV1, -{ +) -> Result { + go_satisfaction_charged(files, modules, &mut |_| Ok(())) +} + +pub(super) fn go_satisfaction_charged( + files: &[T], + modules: &ModuleImportIndexV1<'_, T>, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result { let scopes = files .iter() .map(|file| { - let file = file.as_ref(); - (file.extraction.language.as_str() == "go" && !file.artifacts.go_method_sets.is_empty()) - .then(|| package_scope(file)) + if file.language() != "go" { + return Ok(None); + } + let file = file.binding()?; + Ok((!file.go_method_sets().is_empty()).then(|| package_scope(file))) }) - .collect::>(); + .collect::, CodeIndexProductionErrorV1>>()?; let mut named_types = HashMap::<(&str, &str), Vec<(&SymbolOccurrenceId, SourceSpan)>>::new(); let mut method_sets = HashMap::, HashMap>::new(); let mut promotes = HashMap::, Vec>::new(); @@ -193,10 +200,9 @@ where let Some(scope) = scope.as_deref() else { continue; }; - let file = file.as_ref(); + let file = file.binding()?; let aliases = file - .artifacts - .imports + .imports() .iter() .filter(|row| !row.is_glob && !row.is_public) .filter_map(|row| { @@ -209,12 +215,11 @@ where .collect(); let go_file = GoFileV1 { scope, aliases }; let symbols = file - .artifacts - .symbols + .symbols() .iter() .map(|symbol| (&symbol.occurrence, symbol)) .collect::>(); - for bound in &file.artifacts.go_method_sets { + for bound in file.go_method_sets() { let Some(symbol) = symbols.get(&bound.occurrence) else { continue; }; @@ -374,6 +379,14 @@ where let methods = match expansion { Ok(methods) => methods, Err(reference_name) => { + super::resolution_view::reserve_resolution_vec(&mut gaps, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + interface + .occurrence + .as_str() + .len() + .saturating_add(reference_name.len()), + ))?; gaps.push(gap(reference_name)); continue; } @@ -386,6 +399,13 @@ where continue; } for (occurrence, span) in named_types.get(owner).into_iter().flatten() { + super::resolution_view::reserve_resolution_vec(&mut edges, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + super::resident_bytes::edge_endpoint_heap_bytes( + occurrence, + interface.occurrence, + ), + ))?; edges.push(CanonicalRelationEdgeV1 { from_occurrence: (*occurrence).clone(), to_occurrence: interface.occurrence.clone(), @@ -434,6 +454,14 @@ where }) .min(); if let Some(reference_name) = undecided.and_then(|owner| effective[owner].reason.as_ref()) { + super::resolution_view::reserve_resolution_vec(&mut gaps, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + interface + .occurrence + .as_str() + .len() + .saturating_add(reference_name.len()), + ))?; gaps.push(gap(reference_name)); } } @@ -441,7 +469,7 @@ where edges.dedup(); gaps.sort(); gaps.dedup(); - GoSatisfactionV1 { edges, gaps } + Ok(GoSatisfactionV1 { edges, gaps }) } /// What an embedded field or alias target promotes to its owner. diff --git a/crates/tracedecay-code-index/src/production/graph_pages.rs b/crates/tracedecay-code-index/src/production/graph_pages.rs index 7dbfb253f5..e4bcd4c52f 100644 --- a/crates/tracedecay-code-index/src/production/graph_pages.rs +++ b/crates/tracedecay-code-index/src/production/graph_pages.rs @@ -7,8 +7,8 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tracedecay_domain::{ - CanonicalRelationEdgeV1, FileOccurrenceId, ManifestDigest, SanitizedCodeFileV1, - SymbolOccurrenceId, + CanonicalRelationEdgeV1, CodeGenerationId, FileOccurrenceId, ManifestDigest, + SanitizedCodeFileV1, SanitizedCodeSnapshotV1, SymbolOccurrenceId, }; use tracedecay_graph_db::{GraphDbError, GraphEntityId, GraphNamespace, GraphSpillRowFootprint}; @@ -20,6 +20,7 @@ use crate::graph_projection::{ use crate::lineage::LineageSymbolRecordV1; use super::graph_page_store::CodeGraphPageBuildFootprintV1; +use super::resolution_view::ResolutionMemoryChangeV1; use super::{ CodeIndexProductionErrorV1, CodeIndexPublishedGenerationV1, FileGenerationArtifactsV1, }; @@ -139,17 +140,13 @@ impl CodeIndexPublishedGenerationV1 { }) }) .collect::, _>>()?; - let files_by_occurrence = snapshot_files - .iter() - .map(|(occurrence, (_, file))| (occurrence, *file)) - .collect::>(); let artifacts_by_occurrence = self .files .iter() .map(|file| (&file.extraction.file_occurrence_id, file)) .collect::>(); - let mut owners = BTreeMap::::new(); + let mut owners = BTreeMap::<&SymbolOccurrenceId, &FileOccurrenceId>::new(); for file in &self.files { let occurrence = &file.extraction.file_occurrence_id; snapshot_files.get(occurrence).ok_or_else(|| { @@ -170,9 +167,9 @@ impl CodeIndexPublishedGenerationV1 { .map(|symbol| &symbol.occurrence), ) { - match owners.insert(owned.clone(), occurrence.clone()) { + match owners.insert(owned, occurrence) { None => {} - Some(existing) if existing == *occurrence => {} + Some(existing) if existing == occurrence => {} Some(_) => { return Err(CodeIndexProductionErrorV1::Contract( "one graph symbol occurrence belongs to multiple files".to_owned(), @@ -182,87 +179,20 @@ impl CodeIndexPublishedGenerationV1 { } } - let mut edges = BTreeMap::>::new(); - for edge in &self.edges { - if let Some(owner) = owners.get(&edge.from_occurrence) { - edges.entry(owner.clone()).or_default().push(edge); - } - } - - let mut unresolved = - BTreeMap::>::new(); - for reference in &self.unresolved_calls { - if let Some(owner) = owners.get(&reference.from_occurrence) { - unresolved.entry(owner.clone()).or_default().push(reference); - } - } - - let mut placeholder_owners = BTreeMap::::new(); - for (file_occurrence_id, page_edges) in &edges { - for edge in page_edges { - if !owners.contains_key(&edge.to_occurrence) { - placeholder_owners - .entry(edge.to_occurrence.clone()) - .and_modify(|owner| { - if file_occurrence_id < owner { - *owner = file_occurrence_id.clone(); - } - }) - .or_insert_with(|| file_occurrence_id.clone()); - } - } - } - - for (position, snapshot_file) in self.snapshot.files.iter().enumerate() { - let file_key = u32::try_from(position).map_err(|_| { - CodeIndexProductionErrorV1::Contract( - "code graph page file key exceeds u32".to_owned(), - ) - })?; - let occurrence = &snapshot_file.file_occurrence_id; - let page_edges = edges.remove(occurrence).unwrap_or_default(); - let target_files = page_edges - .iter() - .filter_map(|edge| { - owners - .get(&edge.to_occurrence) - .or_else(|| placeholder_owners.get(&edge.to_occurrence)) - .filter(|target| *target != occurrence) - .map(|target| (edge.to_occurrence.clone(), target.clone())) - }) - .collect(); - let owned_placeholders = placeholder_owners - .iter() - .filter(|(_, owner)| *owner == occurrence) - .map(|(occurrence, _)| occurrence.clone()) - .collect(); - let placeholder_targets = page_edges - .iter() - .filter(|edge| !owners.contains_key(&edge.to_occurrence)) - .map(|edge| edge.to_occurrence.clone()) - .collect(); - visit(seal_code_graph_page( - CodeGraphPageInputsV1 { - file_key, - snapshot_file, - artifacts: artifacts_by_occurrence - .get(occurrence) - .map(|file| file.as_ref()), - edges: page_edges.into_iter().cloned().collect(), - unresolved_calls: unresolved - .remove(occurrence) - .unwrap_or_default() - .into_iter() - .cloned() - .collect(), - target_files, - placeholder_targets, - owned_placeholders, - }, - &files_by_occurrence, - &self.manifest.generation_id, - )?)?; - } + for_each_code_graph_page( + &self.snapshot, + &self.manifest.generation_id, + &owners, + &self.edges, + &self.unresolved_calls, + &mut |_| Ok(()), + &mut |file| { + Ok(artifacts_by_occurrence + .get(&file.file_occurrence_id) + .map(|artifacts| Arc::clone(artifacts))) + }, + &mut visit, + )?; Ok(()) } } @@ -270,7 +200,8 @@ impl CodeIndexPublishedGenerationV1 { /// The symbol occurrences a file's graph page owns: its chunks' symbols and /// its symbols. pub(super) fn owned_occurrences(file: &FileGenerationArtifactsV1) -> BTreeSet<&SymbolOccurrenceId> { - file.artifacts + let occurrences = file + .artifacts .chunks .chunks .iter() @@ -280,8 +211,12 @@ pub(super) fn owned_occurrences(file: &FileGenerationArtifactsV1) -> BTreeSet<&S .symbols .iter() .map(|symbol| &symbol.occurrence), - ) - .collect() + ); + let mut owners = BTreeSet::new(); + for occurrence in occurrences { + owners.insert(occurrence); + } + owners } /// Everything one file's graph page holds besides what its file says. @@ -303,6 +238,16 @@ pub(super) fn seal_code_graph_page( inputs: CodeGraphPageInputsV1<'_>, files_by_occurrence: &BTreeMap<&FileOccurrenceId, &SanitizedCodeFileV1>, generation: &tracedecay_domain::CodeGenerationId, +) -> Result { + seal_code_graph_page_charged(inputs, files_by_occurrence, generation, 0, &mut |_| Ok(())) +} + +fn seal_code_graph_page_charged( + inputs: CodeGraphPageInputsV1<'_>, + files_by_occurrence: &BTreeMap<&FileOccurrenceId, &SanitizedCodeFileV1>, + generation: &CodeGenerationId, + base: usize, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, ) -> Result { let check = || Ok::<(), GraphDbError>(()); let CodeGraphPageInputsV1 { @@ -338,9 +283,7 @@ pub(super) fn seal_code_graph_page( placeholder_targets, owned_placeholders, }; - let encoded = serde_json::to_vec(&page).map_err(|error| { - CodeIndexProductionErrorV1::Contract(format!("code graph page encoding failed: {error}")) - })?; + let encoded = super::partitioned_codec::encode_json_rows_charged(&page, base, retain)?; let page_digest = ManifestDigest::from_sha256_bytes(&Sha256::digest(&encoded)) .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; let build_footprint = page_build_footprint(&page, generation, encoded.len())?; @@ -353,3 +296,284 @@ pub(super) fn seal_code_graph_page( build_footprint, }) } + +/// Emit the same canonical graph pages from either decoded files or a staged +/// source. The loader's one file stays alive only until its page is sealed. +#[allow(clippy::too_many_arguments)] +pub(super) fn for_each_code_graph_page( + snapshot: &SanitizedCodeSnapshotV1, + generation: &CodeGenerationId, + owners: &BTreeMap<&SymbolOccurrenceId, &FileOccurrenceId>, + all_edges: &[CanonicalRelationEdgeV1], + unresolved_calls: &[CodeIndexUnresolvedReferenceV1], + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, + load: &mut impl FnMut( + &SanitizedCodeFileV1, + ) + -> Result>, CodeIndexProductionErrorV1>, + visit: &mut impl FnMut(SealedCodeGraphPageV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<(), CodeIndexProductionErrorV1> { + let mut routing = super::resident_bytes::btree_bytes( + snapshot.files.len(), + size_of::<(&FileOccurrenceId, &SanitizedCodeFileV1)>(), + ); + retain(u64::try_from(routing).map_err(|_| { + CodeIndexProductionErrorV1::Contract("graph routing charge exceeds u64".to_owned()) + })?)?; + let mut charge = |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut routing, |routing| { + retain(u64::try_from(routing).map_err(|_| { + CodeIndexProductionErrorV1::Contract("graph routing charge exceeds u64".to_owned()) + })?) + }) + }; + let files_by_occurrence = snapshot + .files + .iter() + .map(|file| (&file.file_occurrence_id, file)) + .collect::>(); + let mut edges = BTreeMap::<&FileOccurrenceId, Vec<&CanonicalRelationEdgeV1>>::new(); + for edge in all_edges { + if let Some(owner) = owners.get(&edge.from_occurrence) { + if !edges.contains_key(*owner) { + let before = super::resident_bytes::btree_bytes( + edges.len(), + size_of::<(&FileOccurrenceId, Vec<&CanonicalRelationEdgeV1>)>(), + ); + let after = super::resident_bytes::btree_bytes( + edges.len().saturating_add(1), + size_of::<(&FileOccurrenceId, Vec<&CanonicalRelationEdgeV1>)>(), + ); + charge(ResolutionMemoryChangeV1::Retain( + after.saturating_sub(before), + ))?; + } + let rows = edges.entry(*owner).or_default(); + super::resolution_view::reserve_resolution_vec(rows, 1, &mut charge)?; + rows.push(edge); + } + } + + let mut unresolved = BTreeMap::<&FileOccurrenceId, Vec<&CodeIndexUnresolvedReferenceV1>>::new(); + for reference in unresolved_calls { + if let Some(owner) = owners.get(&reference.from_occurrence) { + if !unresolved.contains_key(*owner) { + let before = super::resident_bytes::btree_bytes( + unresolved.len(), + size_of::<(&FileOccurrenceId, Vec<&CodeIndexUnresolvedReferenceV1>)>(), + ); + let after = super::resident_bytes::btree_bytes( + unresolved.len().saturating_add(1), + size_of::<(&FileOccurrenceId, Vec<&CodeIndexUnresolvedReferenceV1>)>(), + ); + charge(ResolutionMemoryChangeV1::Retain( + after.saturating_sub(before), + ))?; + } + let rows = unresolved.entry(*owner).or_default(); + super::resolution_view::reserve_resolution_vec(rows, 1, &mut charge)?; + rows.push(reference); + } + } + + let mut placeholder_owners = BTreeMap::<&SymbolOccurrenceId, &FileOccurrenceId>::new(); + for (file_occurrence_id, page_edges) in &edges { + for edge in page_edges { + if !owners.contains_key(&edge.to_occurrence) { + if !placeholder_owners.contains_key(&edge.to_occurrence) { + let before = super::resident_bytes::btree_bytes( + placeholder_owners.len(), + size_of::<(&SymbolOccurrenceId, &FileOccurrenceId)>(), + ); + let after = super::resident_bytes::btree_bytes( + placeholder_owners.len().saturating_add(1), + size_of::<(&SymbolOccurrenceId, &FileOccurrenceId)>(), + ); + charge(ResolutionMemoryChangeV1::Retain( + after.saturating_sub(before), + ))?; + } + placeholder_owners + .entry(&edge.to_occurrence) + .and_modify(|owner| { + if *file_occurrence_id < *owner { + *owner = *file_occurrence_id; + } + }) + .or_insert(*file_occurrence_id); + } + } + } + + for (position, snapshot_file) in snapshot.files.iter().enumerate() { + let file_key = u32::try_from(position).map_err(|_| { + CodeIndexProductionErrorV1::Contract("code graph page file key exceeds u32".to_owned()) + })?; + let occurrence = &snapshot_file.file_occurrence_id; + let page_edges = edges.remove(occurrence).unwrap_or_default(); + let page_calls = unresolved.remove(occurrence).unwrap_or_default(); + let mut page_bytes = routing + .saturating_add(super::resident_bytes::vec_bytes( + &page_edges, + page_edges.len(), + |edge| super::resident_bytes::edge_heap_bytes(edge), + )) + .saturating_add(page_edges.len().saturating_mul( + size_of::() - size_of::<&CanonicalRelationEdgeV1>(), + )) + .saturating_add(super::resident_bytes::vec_bytes( + &page_calls, + page_calls.len(), + |call| super::resident_bytes::unresolved_heap_bytes(call), + )) + .saturating_add(page_calls.len().saturating_mul( + size_of::() + - size_of::<&CodeIndexUnresolvedReferenceV1>(), + )); + let mut charge = |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut page_bytes, |page_bytes| { + retain(u64::try_from(page_bytes).map_err(|_| { + CodeIndexProductionErrorV1::Contract("graph page charge exceeds u64".to_owned()) + })?) + }) + }; + let mut target_files = BTreeMap::new(); + let mut placeholder_targets = BTreeSet::new(); + for edge in &page_edges { + if let Some(target) = owners + .get(&edge.to_occurrence) + .or_else(|| placeholder_owners.get(&edge.to_occurrence)) + && *target != occurrence + && !target_files.contains_key(&edge.to_occurrence) + { + let before = super::resident_bytes::btree_bytes( + target_files.len(), + size_of::<(SymbolOccurrenceId, FileOccurrenceId)>(), + ); + let after = super::resident_bytes::btree_bytes( + target_files.len().saturating_add(1), + size_of::<(SymbolOccurrenceId, FileOccurrenceId)>(), + ); + charge(ResolutionMemoryChangeV1::Retain( + after + .saturating_sub(before) + .saturating_add(edge.to_occurrence.as_str().len()) + .saturating_add(target.as_str().len()), + ))?; + target_files.insert(edge.to_occurrence.clone(), (*target).clone()); + } + if !owners.contains_key(&edge.to_occurrence) + && !placeholder_targets.contains(&edge.to_occurrence) + { + let before = super::resident_bytes::btree_bytes( + placeholder_targets.len(), + size_of::(), + ); + let after = super::resident_bytes::btree_bytes( + placeholder_targets.len().saturating_add(1), + size_of::(), + ); + charge(ResolutionMemoryChangeV1::Retain( + after + .saturating_sub(before) + .saturating_add(edge.to_occurrence.as_str().len()), + ))?; + placeholder_targets.insert(edge.to_occurrence.clone()); + } + } + let mut owned_placeholders = BTreeSet::new(); + for (placeholder, owner) in &placeholder_owners { + if *owner != occurrence { + continue; + } + let before = super::resident_bytes::btree_bytes( + owned_placeholders.len(), + size_of::(), + ); + let after = super::resident_bytes::btree_bytes( + owned_placeholders.len().saturating_add(1), + size_of::(), + ); + charge(ResolutionMemoryChangeV1::Retain( + after + .saturating_sub(before) + .saturating_add(placeholder.as_str().len()), + ))?; + owned_placeholders.insert((*placeholder).clone()); + } + let artifacts = load(snapshot_file)?; + if let Some(file) = &artifacts { + page_bytes = page_bytes + .saturating_add(super::resident_bytes::file_bytes(file)) + .saturating_add( + file.artifacts + .chunks + .chunks + .iter() + .map(|chunk| super::resident_bytes::chunk_bytes(chunk)) + .sum::(), + ) + .saturating_add( + file.artifacts + .symbols + .iter() + .map(|symbol| super::resident_bytes::symbol_bytes(symbol)) + .sum::(), + ) + .saturating_add(super::resident_bytes::vec_bytes( + &file.artifacts.imports, + file.artifacts.imports.len(), + super::resident_bytes::import_heap_bytes, + )) + .saturating_add( + file.artifacts + .symbols + .len() + .saturating_mul(size_of::>()), + ); + page_bytes = page_bytes.saturating_add(super::resident_bytes::btree_bytes( + file.artifacts.chunks.chunks.len(), + size_of::<(SymbolOccurrenceId, CodeGraphSymbolBindingV1)>(), + )); + for chunk in &file.artifacts.chunks.chunks { + page_bytes = page_bytes + .saturating_add( + chunk + .anchor + .symbol_occurrence_id + .as_ref() + .map_or(0, |id| id.as_str().len()), + ) + .saturating_add(chunk.anchor.file_occurrence_id.as_str().len()) + .saturating_add(chunk.id.as_str().len()) + .saturating_add(chunk.language_descriptor_revision.as_str().len()) + .saturating_add(snapshot_file.logical_path.len()); + } + } + page_bytes = page_bytes + .saturating_add(size_of::()) + .saturating_add(super::resident_bytes::snapshot_file_heap_bytes( + snapshot_file, + )); + retain(u64::try_from(page_bytes).map_err(|_| { + CodeIndexProductionErrorV1::Contract("graph page charge exceeds u64".to_owned()) + })?)?; + visit(seal_code_graph_page_charged( + CodeGraphPageInputsV1 { + file_key, + snapshot_file, + artifacts: artifacts.as_deref(), + edges: page_edges.into_iter().cloned().collect(), + unresolved_calls: page_calls.into_iter().cloned().collect(), + target_files, + placeholder_targets, + owned_placeholders, + }, + &files_by_occurrence, + generation, + page_bytes, + retain, + )?)?; + } + Ok(()) +} diff --git a/crates/tracedecay-code-index/src/production/helpers.rs b/crates/tracedecay-code-index/src/production/helpers.rs index 22be688a02..3acd19ab6c 100644 --- a/crates/tracedecay-code-index/src/production/helpers.rs +++ b/crates/tracedecay-code-index/src/production/helpers.rs @@ -1,3 +1,4 @@ +use super::resolution_view::ResolutionMemoryChangeV1; use super::*; use std::collections::{BTreeMap, BTreeSet, HashSet}; @@ -15,67 +16,17 @@ use crate::chunks::{ rust_ufcs_impl_type_name, typescript_member_call_path, }; use crate::lineage::LineageSymbolRecordV1; -use crate::production::go_satisfaction::go_satisfaction; use crate::production::module_resolution::{ ModuleImportIndexV1, caller_scope, is_module_import_language, }; -use crate::production::resolution_view::{FileSymbolsByNameV1, ResolutionFileV1, SymbolsByNameV1}; +use crate::production::resolution_view::{ + FileSymbolsByNameV1, ResolutionFileFactsV1, ResolutionFileV1, SymbolsByNameV1, + reserve_resolution_map, reserve_resolution_vec, +}; use crate::production::typescript_resolution::{ ImportBindingOutcomeV1, TypeScriptModuleIndexV1, unique_local_import, }; -pub(crate) struct StagedGenerationV1 { - pub(crate) files: Vec>, - pub(crate) chunks: GenerationChunkManifestV1, - pub(crate) symbols: GenerationSymbolIndexV1, - pub(crate) clone_payloads_reused: u64, - pub(crate) clone_payloads_computed: u64, - pub(crate) clone_stale_invalidations: u64, -} - -pub(crate) fn staged_generation( - generation_id: CodeGenerationId, - mut files: Vec>, -) -> Result { - files.sort_by(|left, right| { - left.artifacts - .chunks - .document - .file_occurrence_id - .cmp(&right.artifacts.chunks.document.file_occurrence_id) - }); - let chunks = { - let _span = tracing::trace_span!("code_index.generation.aggregate_chunks").entered(); - GenerationChunkManifestV1::from_validated_files( - generation_id.clone(), - files - .iter() - .map(|file| file.artifacts.chunks.clone()) - .collect(), - ) - } - .map_err(CodeIndexProductionErrorV1::Increment)?; - let symbols = { - let _span = tracing::trace_span!("code_index.generation.aggregate_symbols").entered(); - GenerationSymbolIndexV1::new( - generation_id, - files - .iter() - .flat_map(|file| file.artifacts.symbols.clone()) - .collect(), - ) - } - .map_err(CodeIndexProductionErrorV1::Lineage)?; - Ok(StagedGenerationV1 { - files, - chunks, - symbols, - clone_payloads_reused: 0, - clone_payloads_computed: 0, - clone_stale_invalidations: 0, - }) -} - /// Pin one descriptor registry to the languages this generation can actually /// index. The same registry instance shape is used by intake, generation /// sealing, and capability emission, so capability pins cannot disagree with @@ -177,7 +128,7 @@ pub(crate) fn file_coverage(file: &FileGenerationArtifactsV1) -> CoverageSummary coverage } -fn add_coverage(left: CoverageSummaryV1, right: CoverageSummaryV1) -> CoverageSummaryV1 { +pub(super) fn add_coverage(left: CoverageSummaryV1, right: CoverageSummaryV1) -> CoverageSummaryV1 { CoverageSummaryV1 { files_eligible: left.files_eligible.saturating_add(right.files_eligible), files_excluded: left.files_excluded.saturating_add(right.files_excluded), @@ -310,109 +261,256 @@ pub(crate) fn edge_order( )) } -type EdgeEvidenceV1 = ( - Vec, - Vec, - Vec, - u64, -); - -/// `files`' edge evidence resolved whole, with the references the seal -/// cannot decide. -pub(crate) fn collect_edge_evidence( - files: &[T], -) -> Result -where - T: ResolutionFileV1, -{ - // Resolution's whole-set indexes are gone before the per-file copies are - // made, and the one exact-capacity vector never regrows, so the peak is - // the edges this returns plus one sort buffer. - let CrossFileResolutionV1 { - edges, - gaps, - ambiguous_name_drops, - } = resolve_cross_file_references(files)?; - let (edges, abstentions) = edge_evidence(files, edges); - Ok((edges, abstentions, gaps, ambiguous_name_drops)) -} - /// `files`' edge evidence: each file's own edges and `cross_file`, the /// edges resolution derives between them. pub(crate) fn edge_evidence( files: &[T], cross_file: Vec, -) -> (Vec, Vec) +) -> Result< + (Vec, Vec), + CodeIndexProductionErrorV1, +> where T: ResolutionFileV1, { - let per_file = files + let facts = files .iter() - .map(|file| file.as_ref().artifacts.edges.len()) - .sum::(); + .map(ResolutionFileV1::binding) + .collect::, _>>()?; + let per_file = facts.iter().map(|file| file.edges().len()).sum::(); let mut edges = Vec::with_capacity(per_file.saturating_add(cross_file.len())); - for file in files { - edges.extend(file.as_ref().artifacts.edges.iter().cloned()); + for file in &facts { + edges.extend(file.edges().iter().cloned()); } edges.extend(cross_file); edges.sort_by(edge_order); - let mut abstentions = files + let mut abstentions = facts .iter() - .flat_map(|file| file.as_ref().artifacts.edge_abstentions.clone()) + .flat_map(|file| file.edge_abstentions().iter().cloned()) .collect::>(); abstentions.sort(); - (edges, abstentions) + Ok((edges, abstentions)) } -/// Resolve the retained per-file unresolved references against the whole -/// staged file set. Sealing is the first moment every file's symbols exist -/// together, so this is where cross-file call/use/implements edges are -/// derived; incremental generations re-run it over their full carried + -/// re-extracted file set, so an edge disappears with either endpoint. -/// -/// Binding requires a qualified reference or parser-attested import path, -/// including Rust parent globs and workspace-crate public re-exports, plus -/// exactly one kind-compatible symbol; Python, Go, Java, and Ruby calls bind -/// through [`ModuleImportIndexV1`]. Other bare names have no cross-file -/// authority and stay unresolved. Bound edges carry the `NameResolved` -/// authority class, not `SyntaxExact`. -/// -/// Go types implement interfaces implicitly, so the whole-set pass also -/// decides Go interface satisfaction from every Go method set. -#[tracing::instrument(name = "code_index.seal.resolve", level = "trace", skip_all)] -pub(crate) fn resolve_cross_file_references( +pub(super) fn resolution_hash_capacity(entries: usize) -> usize { + if entries == 0 { + 0 + } else if entries < 4 { + 3 + } else { + let buckets = entries + .saturating_mul(8) + .div_ceil(7) + .checked_next_power_of_two() + .unwrap_or(usize::MAX); + buckets.saturating_sub(buckets / 8) + } +} + +/// The cold staged route keeps one file's binder memo at a time and admits +/// every retained output before growing or cloning it. The callback receives +/// cumulative live bytes in addition to the caller's retained compact facts. +pub(super) fn resolve_cross_file_references_charged( files: &[T], -) -> Result -where - T: ResolutionFileV1, -{ + retain_output: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result { #[cfg(test)] SEAL_REFERENCE_RESOLUTIONS.with(|resolutions| resolutions.set(resolutions.get() + 1)); - let by_simple_name = { - let _span = tracing::trace_span!("code_index.seal.reference_index").entered(); - FileSymbolsByNameV1::new(files) + let mut retained = 0_usize; + let mut charge = |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut retained, |retained| { + retain_output(u64::try_from(retained).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "resolution output charge exceeds u64".to_owned(), + ) + })?) + }) }; - resolve_references(files, &by_simple_name, None) + let by_name = FileSymbolsByNameV1::new_charged(files, &mut charge)?; + let mut modules = ResolutionModulesV1::new_charged(files, &mut charge)?; + let index_bytes = retained; + let mut edges = Vec::new(); + let mut gaps = Vec::new(); + let mut ambiguous_name_drops = 0_u64; + for index in 0..files.len() { + let output_bytes = super::resident_bytes::vec_bytes( + &edges, + edges.capacity(), + super::resident_bytes::edge_heap_bytes, + ) + .saturating_add(super::resident_bytes::vec_bytes( + &gaps, + gaps.capacity(), + super::resident_bytes::unresolved_heap_bytes, + )); + let mut file_bytes = 0_usize; + let (file_edges, file_gaps, drops) = resolve_one_file_cross_file_references( + files, + &by_name, + &modules, + index, + None, + &mut |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut file_bytes, |file_bytes| { + retain_output( + u64::try_from( + index_bytes + .saturating_add(output_bytes) + .saturating_add(file_bytes), + ) + .map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "resolution output charge exceeds u64".to_owned(), + ) + })?, + ) + }) + }, + )?; + modules.release_file_memo(index); + ambiguous_name_drops = ambiguous_name_drops.saturating_add(drops); + let mut cumulative = index_bytes + .saturating_add(output_bytes) + .saturating_add(file_bytes); + let mut reserve = |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut cumulative, |cumulative| { + retain_output(u64::try_from(cumulative).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "resolution output charge exceeds u64".to_owned(), + ) + })?) + }) + }; + reserve_resolution_vec(&mut edges, file_edges.len(), &mut reserve)?; + reserve_resolution_vec(&mut gaps, file_gaps.len(), &mut reserve)?; + edges.extend(file_edges); + gaps.extend(file_gaps); + } + let mut has_go = false; + for file in files { + if !file.binding()?.go_method_sets().is_empty() { + has_go = true; + break; + } + } + if has_go { + let mut cumulative = index_bytes + .saturating_add(super::resident_bytes::vec_bytes( + &edges, + edges.capacity(), + super::resident_bytes::edge_heap_bytes, + )) + .saturating_add(super::resident_bytes::vec_bytes( + &gaps, + gaps.capacity(), + super::resident_bytes::unresolved_heap_bytes, + )); + let mut charge = |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut cumulative, |cumulative| { + retain_output(u64::try_from(cumulative).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "resolution output charge exceeds u64".to_owned(), + ) + })?) + }) + }; + let satisfaction = super::go_satisfaction::go_satisfaction_charged( + files, + modules.modules()?, + &mut charge, + )?; + reserve_resolution_vec(&mut edges, satisfaction.edges.len(), &mut charge)?; + reserve_resolution_vec(&mut gaps, satisfaction.gaps.len(), &mut charge)?; + edges.extend(satisfaction.edges); + gaps.extend(satisfaction.gaps); + } + let sort_bytes = index_bytes + .saturating_add(super::resident_bytes::vec_bytes( + &edges, + edges.capacity(), + super::resident_bytes::edge_heap_bytes, + )) + .saturating_add(super::resident_bytes::vec_bytes( + &gaps, + gaps.capacity(), + super::resident_bytes::unresolved_heap_bytes, + )) + .saturating_add( + edges + .len() + .div_ceil(2) + .saturating_mul(std::mem::size_of::()), + ); + retain_output(u64::try_from(sort_bytes).map_err(|_| { + CodeIndexProductionErrorV1::Contract("resolution sort charge exceeds u64".to_owned()) + })?)?; + edges.sort_by(edge_order); + edges.dedup(); + drop(modules); + drop(by_name); + retain_output( + u64::try_from( + super::resident_bytes::vec_bytes( + &edges, + edges.capacity(), + super::resident_bytes::edge_heap_bytes, + ) + .saturating_add(super::resident_bytes::vec_bytes( + &gaps, + gaps.capacity(), + super::resident_bytes::unresolved_heap_bytes, + )), + ) + .map_err(|_| { + CodeIndexProductionErrorV1::Contract("resolution output charge exceeds u64".to_owned()) + })?, + )?; + Ok(CrossFileResolutionV1 { + edges, + gaps, + ambiguous_name_drops, + }) } /// The retained references a resolution pass decides: per file index, the /// indices of its references to decide, ascending. pub(crate) type ReferenceSelectionV1 = [(usize, Vec)]; -/// Resolves only `selection`'s references against the whole file set whose -/// symbols `by_simple_name` indexes. Each reference binds exactly as -/// [`resolve_cross_file_references`] binds it; the result is the edges those -/// references contribute and the ambiguous calls among them. -#[tracing::instrument(name = "code_index.seal.resolve_selected", level = "trace", skip_all)] -pub(crate) fn resolve_selected_cross_file_references( +/// Resolve one bounded selected-file window against a reusable module +/// context. The immutable binder and its rule helpers are the same as the +/// ordinary whole-set pass; only scheduling and ownership differ. +pub(super) fn resolve_selected_with_modules( files: &[T], - by_simple_name: &dyn SymbolsByNameV1, + by_name: &dyn SymbolsByNameV1, + modules: &ResolutionModulesV1<'_, T>, selection: &ReferenceSelectionV1, -) -> Result -where - T: ResolutionFileV1, -{ - resolve_references(files, by_simple_name, Some(selection)) + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result { + let mut edges = Vec::new(); + let mut gaps = Vec::new(); + let mut ambiguous_name_drops = 0_u64; + for (index, picks) in selection { + let (file_edges, file_gaps, drops) = resolve_one_file_cross_file_references( + files, + by_name, + modules, + *index, + Some(picks), + charge, + )?; + reserve_resolution_vec(&mut edges, file_edges.len(), charge)?; + reserve_resolution_vec(&mut gaps, file_gaps.len(), charge)?; + edges.extend(file_edges); + gaps.extend(file_gaps); + ambiguous_name_drops = ambiguous_name_drops.saturating_add(drops); + } + edges.sort_by(edge_order); + edges.dedup(); + Ok(CrossFileResolutionV1 { + edges, + gaps, + ambiguous_name_drops, + }) } /// A resolution: the cross-file edges, and the references the seal cannot @@ -430,24 +528,30 @@ pub(crate) struct CrossFileResolutionV1 { pub(crate) fn selected_references<'f, T>( files: &'f [T], selection: Option<&ReferenceSelectionV1>, -) -> impl Iterator +) -> Result, CodeIndexProductionErrorV1> where T: ResolutionFileV1, { - let every = selection.is_none().then(|| { - files.iter().enumerate().flat_map(|(index, file)| { - file.as_ref() - .artifacts - .unresolved_references - .iter() - .map(move |reference| (index, reference)) - }) - }); - let selected = selection.into_iter().flatten().flat_map(|(index, picks)| { - let references = &files[*index].as_ref().artifacts.unresolved_references; - picks.iter().map(move |&pick| (*index, &references[pick])) - }); - every.into_iter().flatten().chain(selected) + let mut selected = Vec::new(); + match selection { + None => { + for (index, file) in files.iter().enumerate() { + selected.extend( + file.binding()? + .unresolved_references() + .iter() + .map(|reference| (index, reference)), + ); + } + } + Some(selection) => { + for (index, picks) in selection { + let references = files[*index].binding()?.unresolved_references(); + selected.extend(picks.iter().map(|&pick| (*index, &references[pick]))); + } + } + } + Ok(selected) } /// The module facts resolution consults beside symbols by name, each built @@ -459,96 +563,89 @@ pub(crate) struct ResolutionModulesV1<'f, T> { rust: RustFileIndexV1, typescript: OnceLock, modules: OnceLock>, + module_initialization: Mutex<()>, } impl<'f, T: ResolutionFileV1> ResolutionModulesV1<'f, T> { - pub(crate) fn new(files: &'f [T]) -> Self { - Self { + pub(crate) fn new(files: &'f [T]) -> Result { + Ok(Self { files, - rust: RustFileIndexV1::new(files), + rust: RustFileIndexV1::new(files)?, typescript: OnceLock::new(), modules: OnceLock::new(), - } + module_initialization: Mutex::new(()), + }) } - fn typescript(&self) -> &TypeScriptModuleIndexV1 { - self.typescript - .get_or_init(|| TypeScriptModuleIndexV1::new(self.files)) + pub(super) fn new_charged( + files: &'f [T], + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result { + let modules = Self { + files, + rust: RustFileIndexV1::new_charged(files, charge)?, + typescript: OnceLock::new(), + modules: OnceLock::new(), + module_initialization: Mutex::new(()), + }; + let mut needs_typescript = false; + let mut needs_modules = false; + for file in files { + let facts = file.binding()?; + needs_typescript |= + is_typescript_family(file.language()) && !facts.unresolved_references().is_empty(); + needs_modules |= (is_module_import_language(file.language()) + && !facts.unresolved_references().is_empty()) + || !facts.go_method_sets().is_empty(); + } + if needs_typescript { + let index = TypeScriptModuleIndexV1::new_charged(files, charge)?; + let _ = modules.typescript.set(index); + } + if needs_modules { + let index = ModuleImportIndexV1::new_charged(files, charge)?; + let _ = modules.modules.set(index); + } + Ok(modules) } - fn modules(&self) -> &ModuleImportIndexV1<'f, T> { - self.modules - .get_or_init(|| ModuleImportIndexV1::new(self.files)) + pub(super) fn release_file_memo(&mut self, index: usize) { + if let Some(modules) = self.modules.get_mut() { + modules.release_file_memo(index); + } } -} - -fn resolve_references( - files: &[T], - by_simple_name: &dyn SymbolsByNameV1, - selection: Option<&ReferenceSelectionV1>, -) -> Result -where - T: ResolutionFileV1, -{ - let workers = crate::parallelism::indexing_workers().max(1); - let modules = { - let _span = tracing::trace_span!("code_index.seal.module_index").entered(); - ResolutionModulesV1::new(files) - }; - // Every file resolves against the same immutable whole-set index, so this - // is one ordered fan-out over the indexing pool. Concatenating each file's - // edges in file-index order reproduces the exact sequence the serial loop - // pushed, so the stable sort and dedup below, and therefore every edge - // digest downstream, do not depend on the width. - let units = selection.map_or(files.len(), <[_]>::len); - let per_file = collect_by_file_index_ordered(units, workers, &|unit| { - let (index, picks) = match selection { - Some(selection) => (selection[unit].0, Some(selection[unit].1.as_slice())), - None => (unit, None), - }; - resolve_one_file_cross_file_references(files, by_simple_name, &modules, index, picks) - })?; - let ambiguous_name_drops = per_file - .iter() - .fold(0_u64, |total, (_, _, drops)| total.saturating_add(*drops)); - // Satisfaction needs every Go method set, so only a whole-set pass - // decides it, and only a file set with Go types builds the module index. - let satisfaction = if selection.is_none() - && files - .iter() - .any(|file| !file.as_ref().artifacts.go_method_sets.is_empty()) - { - go_satisfaction(files, modules.modules()) - } else { - Default::default() - }; - drop(modules); - let mut edges = Vec::with_capacity( - per_file - .iter() - .map(|(edges, _, _)| edges.len()) - .sum::() - .saturating_add(satisfaction.edges.len()), - ); - let mut gaps = satisfaction.gaps; - for (file_edges, file_gaps, _) in per_file { - edges.extend(file_edges); - gaps.extend(file_gaps); + fn typescript(&self) -> Result<&TypeScriptModuleIndexV1, CodeIndexProductionErrorV1> { + if let Some(index) = self.typescript.get() { + return Ok(index); + } + let _guard = self.module_initialization.lock().map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "resolution module initialization lock poisoned".to_owned(), + ) + })?; + if let Some(index) = self.typescript.get() { + return Ok(index); + } + let index = TypeScriptModuleIndexV1::new(self.files)?; + Ok(self.typescript.get_or_init(|| index)) } - edges.extend(satisfaction.edges); - { - let _span = tracing::trace_span!("code_index.seal.edge_materialization").entered(); - { - edges.sort_by(edge_order); - edges.dedup(); + + fn modules(&self) -> Result<&ModuleImportIndexV1<'f, T>, CodeIndexProductionErrorV1> { + if let Some(index) = self.modules.get() { + return Ok(index); } - }; - Ok(CrossFileResolutionV1 { - edges, - gaps, - ambiguous_name_drops, - }) + let _guard = self.module_initialization.lock().map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "resolution module initialization lock poisoned".to_owned(), + ) + })?; + if let Some(index) = self.modules.get() { + return Ok(index); + } + let index = ModuleImportIndexV1::new(self.files)?; + Ok(self.modules.get_or_init(|| index)) + } } /// Retained call sites whose import binding names project code the seal @@ -556,7 +653,7 @@ where /// workspace-package specifier that reaches no indexed file, or a module that /// does not define the imported name (a default import, an `export { x }` of /// a name the module neither declares nor imports from project code). -/// Python, Go, Java, and Ruby: see [`ModuleImportIndexV1::call_gaps`]. +/// Python, Go, Java, and Ruby: see [`ModuleImportIndexV1::call_gaps_charged`]. /// These are the sites `callers` and `file_dependents` must disclose as gaps; /// an import of an external dependency is not one of them. /// `selection`, when given, limits the sites decided to its references. @@ -564,124 +661,115 @@ pub(crate) fn unresolved_import_calls( files: &[T], by_simple_name: &dyn SymbolsByNameV1, selection: Option<&ReferenceSelectionV1>, -) -> Vec +) -> Result, CodeIndexProductionErrorV1> where T: ResolutionFileV1, { - let modules = ResolutionModulesV1::new(files); - let selected = || selected_references(files, selection); - let mut unresolved = - if selected().any(|(index, _)| is_module_import_language(files[index].language())) { - modules.modules().call_gaps(selected()) - } else { - Vec::new() - }; + let modules = ResolutionModulesV1::new(files)?; + unresolved_import_calls_with_modules( + files, + by_simple_name, + &modules, + selection, + &mut |_| Ok(()), + ) +} + +pub(super) fn unresolved_import_calls_with_modules( + files: &[T], + by_simple_name: &dyn SymbolsByNameV1, + modules: &ResolutionModulesV1<'_, T>, + selection: Option<&ReferenceSelectionV1>, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result, CodeIndexProductionErrorV1> { + let selected = selected_references(files, selection)?; + let mut unresolved = if selected + .iter() + .any(|(index, _)| is_module_import_language(files[*index].language())) + { + modules + .modules()? + .call_gaps_charged(selected.iter().copied(), charge)? + } else { + Vec::new() + }; let mut receiver_gaps = HashSet::new(); - for (index, reference) in selected() { + for (index, reference) in selected { if !is_typescript_family(files[index].language()) || reference.kind != RelationEdgeKindV1::Calls || reference.reference_name.contains("::") { continue; } - let file = files[index].as_ref(); + let file = files[index].binding()?; match typescript_import_call_outcome( files, by_simple_name, - modules.typescript(), + modules.typescript()?, file, reference, - ) { - Some(ImportBindingOutcomeV1::Unresolved) => unresolved.push(reference.clone()), + )? { + Some(ImportBindingOutcomeV1::Unresolved) => { + reserve_resolution_vec(&mut unresolved, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + super::resident_bytes::unresolved_heap_bytes(reference), + ))?; + unresolved.push(reference.clone()); + } // A member call no import names is a receiver call nothing binds. // It can only be a missing edge to a project symbol of that name, // and one site per caller and name is the whole disclosure. None => { if let Some((_, member)) = reference.reference_name.rsplit_once('.') - && by_simple_name.get(member).is_some() + && by_simple_name.get(member)?.is_some() && receiver_gaps.insert((&reference.from_occurrence, member)) { + reserve_resolution_vec(&mut unresolved, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + super::resident_bytes::unresolved_heap_bytes(reference), + ))?; unresolved.push(reference.clone()); } } Some(_) => {} } } - unresolved + Ok(unresolved) } /// How a TypeScript-family call binds through the file's import of its /// callee: a bare imported name, or a member path read from an imported /// module namespace. `None` when no unique local import names the callee. -fn typescript_import_call_outcome<'a, T>( - files: &'a [T], - by_simple_name: &'a dyn SymbolsByNameV1, +fn typescript_import_call_outcome( + files: &[T], + by_simple_name: &dyn SymbolsByNameV1, typescript_modules: &TypeScriptModuleIndexV1, - file: &FileGenerationArtifactsV1, + file: &dyn ResolutionFileFactsV1, reference: &CodeIndexUnresolvedReferenceV1, -) -> Option> +) -> Result, CodeIndexProductionErrorV1> where T: ResolutionFileV1, { if !reference.reference_name.contains('.') { - let binding = unique_import(file, &reference.reference_name, reference.kind)?; - return Some(typescript_modules.resolve_import_binding( - files, - by_simple_name, - binding, - reference.kind, - )); + let Some(binding) = unique_import(file, &reference.reference_name, reference.kind) else { + return Ok(None); + }; + return typescript_modules + .resolve_import_binding(files, by_simple_name, binding, reference.kind) + .map(Some); } if reference.kind != RelationEdgeKindV1::Calls { - return None; + return Ok(None); } - let (head, members) = typescript_member_call_path(&reference.reference_name)?; - let binding = unique_import(file, head, reference.kind)?; - Some(typescript_modules.resolve_member_call( - files, - by_simple_name, - binding, - members, - reference.kind, - )) -} - -/// One ordered, panic-contained fan-out over file indices on the indexing pool. -/// -/// Results come back in index order whatever the completion order was, so a -/// caller may concatenate them and keep the sequence a serial loop produced. -fn collect_by_file_index_ordered( - count: usize, - workers: usize, - operation: &(dyn Fn(usize) -> R + Send + Sync), -) -> Result, CodeIndexProductionErrorV1> -where - R: Send, -{ - if count < 2 || workers < 2 { - return Ok((0..count).map(operation).collect()); - } - crate::parallelism::install(|| { - (0..count) - .into_par_iter() - .map(|index| { - crate::parallelism::with_background_cpu_permit(|| { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| operation(index))) - .map_err(|payload| { - crate::parallelism::CodeIndexParallelismErrorV1::from_panic_payload( - index, &*payload, - ) - }) - }) - }) - // Collecting every unit before short-circuiting keeps the reported - // failure the lowest-index one, as the serial loop's would be. - .collect::>() - }) - .map_err(CodeIndexProductionErrorV1::from)? - .into_iter() - .collect::, _>>() - .map_err(CodeIndexProductionErrorV1::from) + let Some((head, members)) = typescript_member_call_path(&reference.reference_name) else { + return Ok(None); + }; + let Some(binding) = unique_import(file, head, reference.kind) else { + return Ok(None); + }; + typescript_modules + .resolve_member_call(files, by_simple_name, binding, members, reference.kind) + .map(Some) } /// Resolve one file's retained unresolved references against the whole staged @@ -697,11 +785,15 @@ fn resolve_one_file_cross_file_references( modules: &ResolutionModulesV1<'_, T>, index: usize, picks: Option<&[usize]>, -) -> ( - Vec, - Vec, - u64, -) + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result< + ( + Vec, + Vec, + u64, + ), + CodeIndexProductionErrorV1, +> where T: ResolutionFileV1, { @@ -712,7 +804,7 @@ where let mut edges = Vec::new(); let mut gaps = Vec::new(); let mut ambiguous_name_drops = 0_u64; - let references = &files[index].as_ref().artifacts.unresolved_references; + let references = files[index].binding()?.unresolved_references(); let every = picks.is_none().then(|| references.iter()); let picked = picks.into_iter().flatten().map(|&pick| &references[pick]); for reference in every.into_iter().flatten().chain(picked) { @@ -738,9 +830,20 @@ where index, reference, &mut ambiguous, - ); + charge, + )?; (resolved, ambiguous) }; + if !resolved_references.contains_key(&cache_key) { + reserve_resolution_map(&mut resolved_references, charge)?; + } + if let Some(ReferenceResolutionV1::Bound(_, targets)) = &resolved.0 { + charge(ResolutionMemoryChangeV1::Retain( + super::resident_bytes::vec_bytes(targets, targets.len(), |target| { + target.as_str().len() + }), + ))?; + } resolved_references.insert(cache_key, resolved.clone()); resolved }; @@ -751,6 +854,10 @@ where Some(ReferenceResolutionV1::Bound(target_index, targets)) => (target_index, targets), Some(ReferenceResolutionV1::Ambiguous) => { if reference.kind == RelationEdgeKindV1::Calls { + reserve_resolution_vec(&mut gaps, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + super::resident_bytes::unresolved_heap_bytes(reference), + ))?; gaps.push(reference.clone()); } continue; @@ -760,15 +867,24 @@ where if target_index == index && !same_file_binds { continue; } - edges.extend(targets.into_iter().map(|target| CanonicalRelationEdgeV1 { - from_occurrence: reference.from_occurrence.clone(), - to_occurrence: target, - kind: reference.kind, - authority: EdgeAuthorityV1::NameResolved, - evidence_span: reference.evidence_span, - })); + for target in targets { + reserve_resolution_vec(&mut edges, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + super::resident_bytes::edge_endpoint_heap_bytes( + &reference.from_occurrence, + &target, + ), + ))?; + edges.push(CanonicalRelationEdgeV1 { + from_occurrence: reference.from_occurrence.clone(), + to_occurrence: target, + kind: reference.kind, + authority: EdgeAuthorityV1::NameResolved, + evidence_span: reference.evidence_span, + }); + } } - (edges, gaps, ambiguous_name_drops) + Ok((edges, gaps, ambiguous_name_drops)) } #[cfg(test)] @@ -808,19 +924,17 @@ fn cross_file_target_kind_is_compatible( index: usize, kind: RelationEdgeKindV1, symbol: &LineageSymbolRecordV1, -) -> bool { - relation_target_kind_is_compatible(kind, &symbol.kind) +) -> Result { + Ok(relation_target_kind_is_compatible(kind, &symbol.kind) && (files[index].language() != "rust" || kind != RelationEdgeKindV1::Calls || symbol.kind != "struct" // Canonical artifacts sort arities by occurrence. Only tuple // structs have a parser-observed callable parameter list. - || files[index] - .as_ref() - .artifacts - .callable_arities + || files[index].binding()? + .callable_arities() .binary_search_by(|row| row.occurrence.cmp(&symbol.occurrence)) - .is_ok()) + .is_ok())) } fn resolve_cross_file_reference( @@ -830,48 +944,55 @@ fn resolve_cross_file_reference( index: usize, reference: &CodeIndexUnresolvedReferenceV1, ambiguous: &mut bool, -) -> Option + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result, CodeIndexProductionErrorV1> where T: ResolutionFileV1, { if reference.ambiguous_local { - return Some(ReferenceResolutionV1::Ambiguous); + return Ok(Some(ReferenceResolutionV1::Ambiguous)); } let rust = &modules.rust; - let file = files[index].as_ref(); + let source = &files[index]; // These languages bind one exact module member through their own import // and package rules, never by name matching. - if is_module_import_language(file.extraction.language.as_str()) { - return match modules.modules().call_outcome(index, reference)? { - ImportBindingOutcomeV1::Bound(target_index, symbol) => Some( - ReferenceResolutionV1::Bound(target_index, vec![symbol.occurrence.clone()]), - ), - ImportBindingOutcomeV1::External - | ImportBindingOutcomeV1::Unresolved - | ImportBindingOutcomeV1::ValueMember => None, - }; + if is_module_import_language(source.language()) { + return Ok( + match modules.modules()?.call_outcome(index, reference, charge)? { + Some(ImportBindingOutcomeV1::Bound(target_index, symbol)) => Some( + ReferenceResolutionV1::Bound(target_index, vec![symbol.occurrence.clone()]), + ), + Some( + ImportBindingOutcomeV1::External + | ImportBindingOutcomeV1::Unresolved + | ImportBindingOutcomeV1::ValueMember, + ) + | None => None, + }, + ); } - if file.extraction.language.as_str() == "rust" + if source.language() == "rust" && reference.kind == RelationEdgeKindV1::Calls && reference.reference_name.contains('.') { - return None; + return Ok(None); } + let file = source.binding()?; let qualified = reference.reference_name.contains("::"); // A TypeScript-family import binds one exact module and one exact name, // so it resolves through module resolution rather than name matching; // the ubiquity blocklist guards only name-only binding. if !qualified - && is_typescript_family(file.extraction.language.as_str()) + && is_typescript_family(file.language()) && let Some(outcome) = typescript_import_call_outcome( files, by_simple_name, - modules.typescript(), + modules.typescript()?, file, reference, - ) + )? { - return match outcome { + return Ok(match outcome { ImportBindingOutcomeV1::Bound(target_index, symbol) if target_index != index => Some( ReferenceResolutionV1::Bound(target_index, vec![symbol.occurrence.clone()]), ), @@ -879,21 +1000,21 @@ where | ImportBindingOutcomeV1::External | ImportBindingOutcomeV1::Unresolved | ImportBindingOutcomeV1::ValueMember => None, - }; + }); } let import = (!qualified) .then(|| unique_import(file, &reference.reference_name, reference.kind)) .flatten(); - let has_rust_glob = file.extraction.language.as_str() == "rust" - && file.artifacts.imports.iter().any(|binding| binding.is_glob); + let has_rust_glob = + file.language() == "rust" && file.imports().iter().any(|binding| binding.is_glob); if !qualified && import.is_none() && !has_rust_glob { - return None; + return Ok(None); } let simple_name = import .and_then(|binding| binding.imported_name.as_deref()) .or_else(|| reference.reference_name.rsplit("::").next()) .unwrap_or(reference.reference_name.as_str()); - let is_rust = file.extraction.language.as_str() == "rust"; + let is_rust = file.language() == "rust"; let owner_attested = qualified && is_rust && (CROSS_FILE_REFERENCE_BLOCKLIST.contains(&simple_name) @@ -905,25 +1026,26 @@ where || (import.is_some() && CROSS_FILE_REFERENCE_BLOCKLIST.contains(&simple_name)) || cross_file_reference_name_is_blocklisted(&reference.reference_name, owner_attested) { - return None; + return Ok(None); } - let candidates = by_simple_name.get(simple_name)?; - if !qualified - && import.is_none() - && has_rust_glob - && candidates.iter().any(|(candidate_index, symbol)| { - *candidate_index == index + let Some(candidates) = by_simple_name.get(simple_name)? else { + return Ok(None); + }; + if !qualified && import.is_none() && has_rust_glob { + for (candidate_index, symbol) in &candidates { + if *candidate_index == index && cross_file_target_kind_is_compatible( files, *candidate_index, reference.kind, symbol, - ) - }) - { - return None; + )? + { + return Ok(None); + } + } } - let source_path = &file.authority.logical_path; + let source_path = file.logical_path(); let crate_qualified = reference.reference_name.strip_prefix("crate::"); // A blocklisted member (`new`, `read`, `spawn`) is exempt from the // blocklist only behind an owner this file attests as project code; @@ -933,86 +1055,89 @@ where && CROSS_FILE_REFERENCE_BLOCKLIST.contains(&simple_name) && !owner_attested { - return None; + return Ok(None); } // The walk's target-independent half is computed once per reference, not // once per candidate: `new` alone has thousands of candidates. - let walk = (qualified && is_rust) - .then(|| RustQualifiedWalkV1::new(files, rust, index, &reference.reference_name)) - .flatten(); - let compatible = candidates - .iter() - .filter(|(candidate_index, symbol)| { - let target = RustSymbolTargetV1 { - index: *candidate_index, + let walk = if qualified && is_rust { + RustQualifiedWalkV1::new(files, rust, index, &reference.reference_name)? + } else { + None + }; + let mut compatible = Vec::new(); + for (candidate_index, symbol) in &candidates { + if files[*candidate_index].language() != file.language() + || !cross_file_target_kind_is_compatible( + files, + *candidate_index, + reference.kind, symbol, - }; - files[*candidate_index].language() == file.extraction.language.as_str() - && cross_file_target_kind_is_compatible( - files, - *candidate_index, - reference.kind, - symbol, - ) - && match import { + )? + { + continue; + } + let target = RustSymbolTargetV1 { + index: *candidate_index, + symbol, + }; + let matches = match import { + None => { + let direct = match crate_qualified { None => { - let direct = match crate_qualified { - None => { - (has_rust_glob - && rust_parent_glob_import_matches( - files, - rust, - index, - &reference.reference_name, - reference.kind, - target, - )) - // Rust `::` paths bind only through the - // hop-by-hop walk below; a bare file-stem - // match would bind `fs::read` to any crate's - // `fs.rs`. - || (!is_rust - && file_qualified_name_matches( - &reference.reference_name, - files[*candidate_index].logical_path(), - &symbol.qualified_name, - )) - } - Some(crate_path) => rust_crate_qualified_name_matches( - crate_path, - source_path, - files[*candidate_index].logical_path(), - &symbol.qualified_name, - ), - }; - direct - || walk - .as_ref() - .is_some_and(|walk| walk.matches(files, rust, target)) + (has_rust_glob + && rust_parent_glob_import_matches( + files, + rust, + index, + &reference.reference_name, + reference.kind, + target, + )?) + // Rust `::` paths bind only through the hop-by-hop + // walk; a file-stem match could cross crate roots. + || (!is_rust + && file_qualified_name_matches( + &reference.reference_name, + files[*candidate_index].logical_path(), + &symbol.qualified_name, + )) + } + Some(crate_path) => rust_crate_qualified_name_matches( + crate_path, + source_path, + files[*candidate_index].logical_path(), + &symbol.qualified_name, + ), + }; + direct + || match &walk { + Some(walk) => walk.matches(files, rust, target)?, + None => false, } - Some(binding) => match binding.module_kind { - ImportModuleKindV1::ProjectRelative => project_import_matches( - binding, - &binding.logical_path, - files[*candidate_index].logical_path(), - &symbol.qualified_name, - ), - ImportModuleKindV1::BareModule - if file.extraction.language.as_str() == "rust" => - { - rust_bare_import_matches(files, rust, index, binding, target, "") - } - ImportModuleKindV1::BareModule => false, - }, + } + Some(binding) => match binding.module_kind { + ImportModuleKindV1::ProjectRelative => project_import_matches( + binding, + &binding.logical_path, + files[*candidate_index].logical_path(), + &symbol.qualified_name, + ), + ImportModuleKindV1::BareModule if file.language() == "rust" => { + rust_bare_import_matches(files, rust, index, binding, target, "")? } - }) - .collect::>(); + ImportModuleKindV1::BareModule => false, + }, + }; + if matches { + compatible.push((candidate_index, symbol)); + } + } // A type-path call may match both an inherent `Type::method` and one or // more `::method` aliases; Rust prefers the inherent, so // keep a unique non-UFCS hit when aliases also matched. Any other choice // between candidates is ambiguous. let compatible = match compatible.as_slice() { - [] => return None, + [] => return Ok(None), [_] => compatible, // `#[cfg]` variants of one Rust definition: one file, one qualified // name, one kind. The call binds that identity at every site. @@ -1038,43 +1163,44 @@ where [_] => inherent, _ => { *ambiguous = true; - return Some(ReferenceResolutionV1::Ambiguous); + return Ok(Some(ReferenceResolutionV1::Ambiguous)); } } } }; - let (target_index, _) = compatible.first()?; - if *target_index == index { - return None; + let Some((target_index, _)) = compatible.first() else { + return Ok(None); + }; + if **target_index == index { + return Ok(None); } - Some(ReferenceResolutionV1::Bound( - *target_index, + Ok(Some(ReferenceResolutionV1::Bound( + **target_index, compatible .iter() .map(|(_, symbol)| symbol.occurrence.clone()) .collect(), - )) + ))) } fn unique_import<'a>( - file: &'a FileGenerationArtifactsV1, + file: &'a dyn ResolutionFileFactsV1, local_name: &str, relation: RelationEdgeKindV1, ) -> Option<&'a CodeIndexImportEvidenceV1> { - if is_typescript_family(file.extraction.language.as_str()) { + if is_typescript_family(file.language()) { // `export { x } from` forwards without binding `x` locally; Rust's // `pub use` does both, so only TypeScript filters forwarding rows. return unique_local_import(file, local_name, relation); } - let mut matches = file.artifacts.imports.iter().filter(|binding| { + let mut matches = file.imports().iter().filter(|binding| { binding.local_name.as_deref() == Some(local_name) && match relation { RelationEdgeKindV1::Calls => binding.namespace == ImportNamespaceV1::Value, RelationEdgeKindV1::Implements | RelationEdgeKindV1::Extends | RelationEdgeKindV1::TypeOf => { - file.extraction.language.as_str() == "rust" - || binding.namespace == ImportNamespaceV1::Type + file.language() == "rust" || binding.namespace == ImportNamespaceV1::Type } _ => true, } @@ -1095,19 +1221,26 @@ struct RustFileIndexV1 { } impl RustFileIndexV1 { - fn new(files: &[T]) -> Self + fn new(files: &[T]) -> Result where T: ResolutionFileV1, { + Self::new_charged(files, &mut |_| Ok(())) + } + + fn new_charged( + files: &[T], + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result { let mut modules = BTreeMap::new(); let mut crate_roots = BTreeMap::new(); // Cargo manifests are found by path without scanning the set per // crate root. - let by_path = files - .iter() - .enumerate() - .map(|(index, file)| (file.logical_path(), index)) - .collect::>(); + let mut by_path = HashMap::new(); + for (index, file) in files.iter().enumerate() { + reserve_resolution_map(&mut by_path, charge)?; + by_path.insert(file.logical_path(), index); + } for (index, file) in files.iter().enumerate() { if file.language() != "rust" { continue; @@ -1123,6 +1256,20 @@ impl RustFileIndexV1 { continue; }; if let Some(module) = rust_file_module(relative) { + let before = super::resident_bytes::btree_bytes( + modules.len(), + std::mem::size_of::<((String, String), Option)>(), + ); + let after = super::resident_bytes::btree_bytes( + modules.len().saturating_add(1), + std::mem::size_of::<((String, String), Option)>(), + ); + charge(ResolutionMemoryChangeV1::Retain( + after + .saturating_sub(before) + .saturating_add(source_root.len()) + .saturating_add(module.len()), + ))?; insert_unique_index( &mut modules, (source_root.to_owned(), module.to_owned()), @@ -1130,15 +1277,28 @@ impl RustFileIndexV1 { ); } if relative == "lib.rs" - && let Some(crate_name) = rust_crate_name(files, &by_path, source_root) + && let Some(crate_name) = rust_crate_name(files, &by_path, source_root)? { + let before = super::resident_bytes::btree_bytes( + crate_roots.len(), + std::mem::size_of::<(String, Option)>(), + ); + let after = super::resident_bytes::btree_bytes( + crate_roots.len().saturating_add(1), + std::mem::size_of::<(String, Option)>(), + ); + charge(ResolutionMemoryChangeV1::Retain( + after + .saturating_sub(before) + .saturating_add(crate_name.capacity()), + ))?; insert_unique_index(&mut crate_roots, crate_name, index); } } - Self { + Ok(Self { modules, crate_roots, - } + }) } fn module(&self, source_path: &str, module: &str) -> Option { @@ -1158,7 +1318,7 @@ fn rust_crate_name( files: &[T], by_path: &HashMap<&str, usize>, source_root: &str, -) -> Option +) -> Result, CodeIndexProductionErrorV1> where T: ResolutionFileV1, { @@ -1168,15 +1328,18 @@ where Some((dir, _)) => format!("{dir}/Cargo.toml"), None => "Cargo.toml".to_owned(), }; - by_path - .get(manifest.as_str()) - .map(|index| &files[*index]) - .and_then(|file| { - file.as_ref().artifacts.symbols.iter().find(|symbol| { + let package_name = match by_path.get(manifest.as_str()) { + Some(index) => files[*index] + .binding()? + .symbols() + .iter() + .find(|symbol| { symbol.simple_name == "name" && symbol.qualified_name.ends_with("::package::name") }) - }) - .and_then(|symbol| symbol.signature.as_deref()) + .and_then(|symbol| symbol.signature.as_deref()), + None => None, + }; + Ok(package_name // A CRLF checkout leaves the pair's trailing `\r` in its signature, // and bare CR is invalid TOML, so parse the trimmed fragment. .and_then(|signature| toml::from_str::(signature.trim_end()).ok()) @@ -1192,7 +1355,7 @@ where .and_then(|name| name.to_str()) .map(str::to_owned) }) - .map(|name| name.replace('-', "_")) + .map(|name| name.replace('-', "_"))) } fn insert_unique_index(index: &mut BTreeMap>, key: K, value: usize) { @@ -1209,26 +1372,28 @@ fn rust_parent_glob_import_matches( local_name: &str, relation: RelationEdgeKindV1, target: RustSymbolTargetV1<'_>, -) -> bool +) -> Result where T: ResolutionFileV1, { - let source = files[source_index].as_ref(); - source - .artifacts - .imports - .iter() - .filter(|binding| { - binding.is_glob - && (binding.module_specifier == "super" - || binding.module_specifier.starts_with("super::")) - }) - .filter_map(|binding| { - rust_relative_module(&binding.module_specifier, &source.authority.logical_path) - }) - .filter_map(|module| rust.module(&source.authority.logical_path, &module)) - .filter_map(|scope_index| unique_import(files[scope_index].as_ref(), local_name, relation)) - .any(|binding| match binding.module_kind { + let source = files[source_index].binding()?; + for binding in source.imports().iter().filter(|binding| { + binding.is_glob + && (binding.module_specifier == "super" + || binding.module_specifier.starts_with("super::")) + }) { + let Some(module) = rust_relative_module(&binding.module_specifier, source.logical_path()) + else { + continue; + }; + let Some(scope_index) = rust.module(source.logical_path(), &module) else { + continue; + }; + let Some(binding) = unique_import(files[scope_index].binding()?, local_name, relation) + else { + continue; + }; + let matches = match binding.module_kind { ImportModuleKindV1::ProjectRelative => project_import_matches( binding, &binding.logical_path, @@ -1236,9 +1401,14 @@ where &target.symbol.qualified_name, ), ImportModuleKindV1::BareModule => { - rust_bare_import_matches(files, rust, source_index, binding, target, "") + rust_bare_import_matches(files, rust, source_index, binding, target, "")? } - }) + }; + if matches { + return Ok(true); + } + } + Ok(false) } fn rust_bare_import_matches( @@ -1248,12 +1418,12 @@ fn rust_bare_import_matches( binding: &CodeIndexImportEvidenceV1, target: RustSymbolTargetV1<'_>, member: &str, -) -> bool +) -> Result where T: ResolutionFileV1, { let mut chain = Vec::new(); - rust_bare_import_chain(files, rust, access_index, binding, member, &mut chain); + rust_bare_import_chain(files, rust, access_index, binding, member, &mut chain)?; rust_export_chain_matches(files, rust, &chain, target) } @@ -1280,12 +1450,14 @@ fn rust_qualified_path_matches( index: usize, reference_name: &str, target: RustSymbolTargetV1<'_>, -) -> bool +) -> Result where T: ResolutionFileV1, { - RustQualifiedWalkV1::new(files, rust, index, reference_name) - .is_some_and(|walk| walk.matches(files, rust, target)) + match RustQualifiedWalkV1::new(files, rust, index, reference_name)? { + Some(walk) => walk.matches(files, rust, target), + None => Ok(false), + } } /// The target-independent half of [`rust_qualified_path_matches`]: the head @@ -1306,50 +1478,53 @@ impl RustQualifiedWalkV1 { rust: &RustFileIndexV1, index: usize, reference_name: &str, - ) -> Option + ) -> Result, CodeIndexProductionErrorV1> where T: ResolutionFileV1, { - let file = files[index].as_ref(); + let file = files[index].binding()?; let segments = reference_name.split("::").collect::>(); if segments.len() < 2 || segments .iter() .any(|segment| segment.is_empty() || segment.contains('<')) { - return None; + return Ok(None); } - let (origin, path) = rust_expand_path_head(rust, file, &segments)?; + let Some((origin, path)) = rust_expand_path_head(rust, file, &segments) else { + return Ok(None); + }; // A path into another workspace crate binds only public targets. let (origin_index, requires_public) = match origin { RustPathOriginV1::InCrate => (index, false), RustPathOriginV1::Crate { root_index } => (root_index, true), }; let root_path = files[origin_index].logical_path(); - let chains = (0..path.len()) - .filter_map(|k| { - let scope_index = rust.module(root_path, &path[..k].join("/"))?; - let member = path[k + 1..] - .iter() - .map(|segment| format!("::{segment}")) - .collect::(); - let mut chain = Vec::new(); - rust_export_chain( - files, - rust, - origin_index, - scope_index, - index, - &path[k], - &member, - requires_public, - &mut BTreeSet::new(), - &mut chain, - ); - Some(chain) - }) - .collect(); - Some(Self { chains }) + let mut chains = Vec::new(); + for k in 0..path.len() { + let Some(scope_index) = rust.module(root_path, &path[..k].join("/")) else { + continue; + }; + let member = path[k + 1..] + .iter() + .map(|segment| format!("::{segment}")) + .collect::(); + let mut chain = Vec::new(); + rust_export_chain( + files, + rust, + origin_index, + scope_index, + index, + &path[k], + &member, + requires_public, + &mut BTreeSet::new(), + &mut chain, + )?; + chains.push(chain); + } + Ok(Some(Self { chains })) } fn matches( @@ -1357,13 +1532,16 @@ impl RustQualifiedWalkV1 { files: &[T], rust: &RustFileIndexV1, target: RustSymbolTargetV1<'_>, - ) -> bool + ) -> Result where T: ResolutionFileV1, { - self.chains - .iter() - .any(|chain| rust_export_chain_matches(files, rust, chain, target)) + for chain in &self.chains { + if rust_export_chain_matches(files, rust, chain, target)? { + return Ok(true); + } + } + Ok(false) } } @@ -1375,10 +1553,10 @@ impl RustQualifiedWalkV1 { /// any of those is `None`. fn rust_expand_path_head( rust: &RustFileIndexV1, - file: &FileGenerationArtifactsV1, + file: &dyn ResolutionFileFactsV1, segments: &[&str], ) -> Option<(RustPathOriginV1, Vec)> { - let source_path = file.authority.logical_path.as_str(); + let source_path = file.logical_path(); let head = segments[0]; if matches!(head, "crate" | "self" | "super") { return rust_relative_path(source_path, segments) @@ -1428,7 +1606,7 @@ fn rust_expand_path_head( /// behind a workspace crate is not. fn rust_qualified_owner_is_project_attested( rust: &RustFileIndexV1, - file: &FileGenerationArtifactsV1, + file: &dyn ResolutionFileFactsV1, reference_name: &str, ) -> bool { let Some((head, _)) = reference_name.split_once("::") else { @@ -1449,10 +1627,10 @@ fn rust_qualified_owner_is_project_attested( if rust.crate_root(head).is_some() { return true; } - let source_path = file.authority.logical_path.as_str(); + let source_path = file.logical_path(); rust_relative_module(&format!("self::{head}"), source_path) .is_some_and(|module| rust.module(source_path, &module).is_some()) - || file.artifacts.symbols.iter().any(|symbol| { + || file.symbols().iter().any(|symbol| { symbol.simple_name == head && relation_target_kind_is_compatible(RelationEdgeKindV1::TypeOf, &symbol.kind) }) @@ -1482,13 +1660,13 @@ fn rust_relative_path(source_path: &str, segments: &[&str]) -> Option( - file: &'a FileGenerationArtifactsV1, + file: &'a dyn ResolutionFileFactsV1, local_name: &str, ) -> Option<&'a CodeIndexImportEvidenceV1> { - let mut matches = - file.artifacts.imports.iter().filter(|binding| { - !binding.is_glob && binding.local_name.as_deref() == Some(local_name) - }); + let mut matches = file + .imports() + .iter() + .filter(|binding| !binding.is_glob && binding.local_name.as_deref() == Some(local_name)); let binding = matches.next()?; matches.next().is_none().then_some(binding) } @@ -1590,7 +1768,7 @@ fn rust_export_resolves_to_target( exported_name: &str, target: RustSymbolTargetV1<'_>, member: &str, -) -> bool +) -> Result where T: ResolutionFileV1, { @@ -1606,7 +1784,7 @@ where false, &mut BTreeSet::new(), &mut chain, - ); + )?; rust_export_chain_matches(files, rust, &chain, target) } @@ -1628,24 +1806,25 @@ fn rust_export_chain( requires_public: bool, visited: &mut BTreeSet<(usize, String)>, chain: &mut Vec, -) where +) -> Result<(), CodeIndexProductionErrorV1> +where T: ResolutionFileV1, { if !visited.insert((scope_index, format!("{exported_name}{member}"))) { - return; + return Ok(()); } let scope_path = files[scope_index].logical_path(); let Some(source_root) = rust_source_root(scope_path) else { - return; + return Ok(()); }; let Some(relative_scope) = scope_path .strip_prefix(source_root) .and_then(|path| path.strip_prefix('/')) else { - return; + return Ok(()); }; let Some(scope_module) = rust_file_module(relative_scope) else { - return; + return Ok(()); }; let mut hop = RustExportHopV1 { origin_index, @@ -1660,43 +1839,39 @@ fn rust_export_chain( import_qualified: None, requires_public, }; - let mut bindings = files[scope_index] - .as_ref() - .artifacts - .imports - .iter() - .filter(|binding| { - binding.local_name.as_deref() == Some(exported_name) - && rust_reexport_visible(files, binding, access_index, scope_index) - }); + let scope = files[scope_index].binding()?; + let mut bindings = scope.imports().iter().filter(|binding| { + binding.local_name.as_deref() == Some(exported_name) + && rust_reexport_visible(files, binding, access_index, scope_index) + }); let binding = match (bindings.next(), bindings.next()) { (Some(binding), None) => binding, _ => { chain.push(hop); - return; + return Ok(()); } }; match binding.module_kind { ImportModuleKindV1::BareModule => { chain.push(hop); - rust_bare_import_chain(files, rust, access_index, binding, member, chain); + rust_bare_import_chain(files, rust, access_index, binding, member, chain)?; } ImportModuleKindV1::ProjectRelative => { let Some(imported_name) = binding.imported_name.as_deref() else { chain.push(hop); - return; + return Ok(()); }; let Some(qualified) = rust_import_qualified_name(&binding.module_specifier, imported_name, scope_path) else { chain.push(hop); - return; + return Ok(()); }; hop.import_qualified = Some(format!("{qualified}{member}")); chain.push(hop); let (module, imported_name) = qualified.rsplit_once("::").unwrap_or(("", &qualified)); let Some(next_scope) = rust.module(scope_path, &module.replace("::", "/")) else { - return; + return Ok(()); }; rust_export_chain( files, @@ -1709,9 +1884,10 @@ fn rust_export_chain( requires_public, visited, chain, - ); + )?; } } + Ok(()) } /// The hops a workspace-crate import (`use krate::module::Name`) walks from @@ -1725,26 +1901,27 @@ fn rust_bare_import_chain( binding: &CodeIndexImportEvidenceV1, member: &str, chain: &mut Vec, -) where +) -> Result<(), CodeIndexProductionErrorV1> +where T: ResolutionFileV1, { let Some(imported_name) = binding.imported_name.as_deref() else { - return; + return Ok(()); }; let mut module = binding.module_specifier.split("::"); let Some(crate_name) = module.next() else { - return; + return Ok(()); }; let module = module.collect::>().join("/"); let Some(root_index) = rust.crate_root(crate_name) else { - return; + return Ok(()); }; let scope_index = if module.is_empty() { root_index } else { let root_path = files[root_index].logical_path(); let Some(index) = rust.module(root_path, &module) else { - return; + return Ok(()); }; index }; @@ -1759,7 +1936,7 @@ fn rust_bare_import_chain( true, &mut BTreeSet::new(), chain, - ); + ) } /// Whether any hop of `chain` names `target`. A hop that requires a public @@ -1769,14 +1946,14 @@ fn rust_export_chain_matches( rust: &RustFileIndexV1, chain: &[RustExportHopV1], target: RustSymbolTargetV1<'_>, -) -> bool +) -> Result where T: ResolutionFileV1, { let target_path = files[target.index].logical_path(); for hop in chain { if hop.requires_public && target.symbol.visibility != "public" { - return false; + return Ok(false); } let root_path = files[hop.origin_index].logical_path(); if (target.index == hop.scope_index @@ -1795,7 +1972,7 @@ where &hop.exported_name, &hop.member, target, - ) + )? || hop.import_qualified.as_deref().is_some_and(|qualified| { rust_crate_qualified_name_matches( qualified, @@ -1805,10 +1982,10 @@ where ) }) { - return true; + return Ok(true); } } - false + Ok(false) } /// The crate-relative path of `exported_name` defined in the module file @@ -1835,17 +2012,17 @@ fn rust_project_import_resolves_to_target( binding: &CodeIndexImportEvidenceV1, target: RustSymbolTargetV1<'_>, member: &str, -) -> bool +) -> Result where T: ResolutionFileV1, { let Some(imported_name) = binding.imported_name.as_deref() else { - return false; + return Ok(false); }; let Some(qualified) = rust_import_qualified_name(&binding.module_specifier, imported_name, scope_path) else { - return false; + return Ok(false); }; let root_path = files[origin_index].logical_path(); if rust_crate_qualified_name_matches( @@ -1854,11 +2031,11 @@ where files[target.index].logical_path(), &target.symbol.qualified_name, ) { - return true; + return Ok(true); } let (module, imported_name) = qualified.rsplit_once("::").unwrap_or(("", &qualified)); let Some(next_scope) = rust.module(scope_path, &module.replace("::", "/")) else { - return false; + return Ok(false); }; rust_export_resolves_to_target( files, @@ -2037,12 +2214,12 @@ fn rust_inherent_method_owned_by_scope_type( exported_name: &str, member: &str, target: RustSymbolTargetV1<'_>, -) -> bool +) -> Result where T: ResolutionFileV1, { if member.is_empty() { - return false; + return Ok(false); } let root_path = files[origin_index].logical_path(); // The member's own identity settles nearly every candidate (another @@ -2054,34 +2231,34 @@ where files[target.index].logical_path(), &target.symbol.qualified_name, ) else { - return false; + return Ok(false); }; if impl_owner.rsplit("::").next() != Some(exported_name) { - return false; + return Ok(false); } - let impl_file = files[target.index].as_ref(); + let impl_file = files[target.index].binding()?; if !rust_method_belongs_to_type_impl(impl_file, target.symbol) { - return false; + return Ok(false); } - let scope = files[scope_index].as_ref(); + let scope = files[scope_index].binding()?; let scope_qualified_type = rust_scope_qualified_name(scope_module, exported_name); - let Some(scope_type) = scope.artifacts.symbols.iter().find(|symbol| { + let Some(scope_type) = scope.symbols().iter().find(|symbol| { relation_target_kind_is_compatible(RelationEdgeKindV1::TypeOf, &symbol.kind) && rust_crate_qualified_name_matches( &scope_qualified_type, root_path, - &scope.authority.logical_path, + scope.logical_path(), &symbol.qualified_name, ) }) else { - return false; + return Ok(false); }; - let shadowed = impl_file.artifacts.symbols.iter().any(|symbol| { + let shadowed = impl_file.symbols().iter().any(|symbol| { symbol.simple_name == exported_name && relation_target_kind_is_compatible(RelationEdgeKindV1::TypeOf, &symbol.kind) }); if shadowed { - return false; + return Ok(false); } let type_target = RustSymbolTargetV1 { index: scope_index, @@ -2091,11 +2268,11 @@ where return rust_qualified_path_matches(files, rust, target.index, impl_owner, type_target); } if target.index == scope_index { - return true; + return Ok(true); } - let impl_path = impl_file.authority.logical_path.as_str(); + let impl_path = impl_file.logical_path(); if let Some(binding) = unique_named_import(impl_file, exported_name) { - return binding.module_kind == ImportModuleKindV1::ProjectRelative + return Ok(binding.module_kind == ImportModuleKindV1::ProjectRelative && rust_project_import_resolves_to_target( files, rust, @@ -2105,20 +2282,19 @@ where binding, type_target, "", - ); + )?); } let glob_modules = impl_file - .artifacts - .imports + .imports() .iter() .filter(|binding| { binding.is_glob && binding.module_kind == ImportModuleKindV1::ProjectRelative }) .filter_map(|binding| rust_relative_module(&binding.module_specifier, impl_path)) .collect::>(); - glob_modules.iter().any(|module| { - rust.module(impl_path, module).is_some_and(|glob_scope| { - rust_export_resolves_to_target( + for module in &glob_modules { + if let Some(glob_scope) = rust.module(impl_path, module) + && rust_export_resolves_to_target( files, rust, origin_index, @@ -2127,24 +2303,25 @@ where exported_name, type_target, "", - ) - }) - }) + )? + { + return Ok(true); + } + } + Ok(false) } fn rust_method_belongs_to_type_impl( - file: &FileGenerationArtifactsV1, + file: &dyn ResolutionFileFactsV1, method: &LineageSymbolRecordV1, ) -> bool { - file.artifacts - .edges + file.edges() .iter() .filter(|edge| { edge.kind == RelationEdgeKindV1::Contains && edge.to_occurrence == method.occurrence }) .filter_map(|edge| { - file.artifacts - .symbols + file.symbols() .iter() .find(|symbol| symbol.occurrence == edge.from_occurrence) }) diff --git a/crates/tracedecay-code-index/src/production/ignored_sources.rs b/crates/tracedecay-code-index/src/production/ignored_sources.rs index c25927b053..2a103a5303 100644 --- a/crates/tracedecay-code-index/src/production/ignored_sources.rs +++ b/crates/tracedecay-code-index/src/production/ignored_sources.rs @@ -1,4 +1,7 @@ use std::collections::BTreeSet; +use std::sync::Arc; + +use tracedecay_runtime_core::resident_memory::ProcessResidentMemoryV1; use serde::{Deserialize, Serialize}; use tracedecay_domain::{ @@ -25,6 +28,9 @@ pub struct CodeIndexIgnoredSourceAdmissionV1 { /// Inputs for one complete immutable code-index generation. #[derive(Clone, Debug)] pub struct CodeIndexBuildRequestV1 { + /// The invoking process's canonical ledger and pressure authority. Source, + /// bounded extraction scratch, and retained construction facts share it. + pub resident_memory: Arc, pub snapshot: SanitizedCodeSnapshotV1, pub captured_files: Vec, /// Capture-reported paths are evidence only; digest equality remains the diff --git a/crates/tracedecay-code-index/src/production/lexical_page_source_tests.rs b/crates/tracedecay-code-index/src/production/lexical_page_source_tests.rs index 344a67a6c1..6ce3129396 100644 --- a/crates/tracedecay-code-index/src/production/lexical_page_source_tests.rs +++ b/crates/tracedecay-code-index/src/production/lexical_page_source_tests.rs @@ -5,6 +5,9 @@ use std::{ atomic::{AtomicUsize, Ordering}, }, }; +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, detected_process_resident_memory_limit_v1, +}; use tracedecay_domain::{ ChunkerRevision, FileOccurrenceId, LanguageId, ManifestDigest, PolicyRevisionId, @@ -592,6 +595,9 @@ fn fixture_for_source_files( omitted_sources: Vec::new(), }; let request = CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot, captured_files: files .into_iter() @@ -638,10 +644,14 @@ fn fixture_for_source_files( let generation = owner .build_and_publish(request, &ActiveControl) .expect("fixture generation publishes"); - let generation = Arc::clone( - generation - .decoded() - .expect("a cold seal holds its generation"), + let reader = generation.sealed_generation().segment_reader(); + let generation = Arc::new( + CodeIndexPublishedGenerationV1::decode_partitioned_sealed( + generation.manifest_bytes(), + &SharedDecodedContentPoolV1::default(), + |request, buffer| reader(request, buffer), + ) + .expect("fixture generation decodes"), ); let mut segments = BTreeMap::new(); let manifest = generation diff --git a/crates/tracedecay-code-index/src/production/memory_store.rs b/crates/tracedecay-code-index/src/production/memory_store.rs index 529d2793a2..b5f2a62f10 100644 --- a/crates/tracedecay-code-index/src/production/memory_store.rs +++ b/crates/tracedecay-code-index/src/production/memory_store.rs @@ -8,13 +8,15 @@ use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex, PoisonError}; +use sha2::{Digest, Sha256}; use tracedecay_domain::{CodeGenerationId, ManifestDigest}; use super::{ - CodeIndexAtomicPublicationPort, CodeIndexGenerationScopeV1, CodeIndexProductionErrorV1, - CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, CodeIndexSealedGenerationV1, - CodeIndexSealedPublicationV1, SealedGenerationSegmentPublicationV1, - SealedGenerationSegmentReadV1, SealedSegmentReaderV1, SharedDecodedContentPoolV1, + CodeIndexAtomicPublicationPort, CodeIndexGenerationScopeV1, CodeIndexPreparationPort, + CodeIndexProductionErrorV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, + CodeIndexSealedGenerationV1, CodeIndexSealedPublicationV1, + SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, SealedSegmentReaderV1, + SharedDecodedContentPoolV1, }; #[derive(Default)] @@ -134,57 +136,178 @@ impl MemorySealedPublicationStoreV1 { }; self.decode(&manifest).map(Some) } +} - /// Seal `generation` and store its segments, without making it active. - pub fn seal( - &self, - generation: &CodeIndexSealedPublicationV1, - ) -> Result, CodeIndexPublicationStoreErrorV1> { - let parent = generation - .manifest() - .parent_generation - .as_ref() - .and_then(|parent| self.manifest_bytes(parent)); - let mut staged = Vec::<(ManifestDigest, Arc<[u8]>)>::new(); - let mut pack = Vec::new(); - let manifest = generation - .encode(parent.as_deref(), |publication| { - match publication { - SealedGenerationSegmentPublicationV1::File { digest, bytes } - | SealedGenerationSegmentPublicationV1::FileEvidence { digest, bytes } - | SealedGenerationSegmentPublicationV1::ResolutionIndex { digest, bytes } => { - staged.push((digest.clone(), Arc::from(bytes))); - } - SealedGenerationSegmentPublicationV1::CodeGraphPage { - page_digest, - bytes, - .. - } => staged.push((page_digest.clone(), Arc::from(bytes))), - SealedGenerationSegmentPublicationV1::GenerationEvidencePage { - bytes, .. - } => pack.extend_from_slice(bytes), - SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { - segment_digest, - .. - } => { - staged.push((segment_digest.clone(), Arc::from(std::mem::take(&mut pack)))) - } +#[derive(Default)] +struct MemoryPreparationStateV1 { + segments: HashMap>, + evidence: Vec, + evidence_pages: u32, + closed: bool, + committed: bool, +} + +/// Unpublished segments of one exact in-memory store and scope. An escaped +/// preparation reader shares the invalidation state, not ownership of bytes +/// after commit or abort. +pub struct MemoryCodeIndexPreparationV1 { + store: MemorySealedPublicationStoreV1, + scope: CodeIndexGenerationScopeV1, + expected: Option, + state: Arc>, +} + +impl MemoryCodeIndexPreparationV1 { + fn close(&mut self) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if state.committed { + return; + } + state.closed = true; + state.segments.clear(); + state.evidence = Vec::new(); + } + + fn unavailable(message: &str) -> CodeIndexPublicationStoreErrorV1 { + CodeIndexPublicationStoreErrorV1::Unavailable(message.to_owned()) + } +} + +impl Drop for MemoryCodeIndexPreparationV1 { + fn drop(&mut self) { + self.close(); + } +} + +impl CodeIndexPreparationPort for MemoryCodeIndexPreparationV1 { + fn write_segment( + &mut self, + publication: SealedGenerationSegmentPublicationV1<'_>, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if state.closed { + return Err(Self::unavailable("publication preparation is closed")); + } + match publication { + SealedGenerationSegmentPublicationV1::GenerationEvidencePage { + page_ordinal, + page_digest, + bytes, + } => { + if page_ordinal != state.evidence_pages + || ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)) + .map_err(|error| Self::unavailable(&error.to_string()))? + != *page_digest + { + return Err(Self::unavailable( + "staged evidence page identity or order does not verify", + )); } - Ok(()) - }) - .map_err(|error| CodeIndexPublicationStoreErrorV1::Unavailable(error.to_string()))?; - let manifest: Arc<[u8]> = Arc::from(manifest); - let mut state = self.state(); - state.segments.extend(staged); - state.manifests.insert( - generation.manifest().generation_id.clone(), - Arc::clone(&manifest), - ); - Ok(manifest) + let next = state + .evidence_pages + .checked_add(1) + .ok_or_else(|| Self::unavailable("staged evidence page count overflowed"))?; + state.evidence.extend_from_slice(bytes); + state.evidence_pages = next; + } + SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { + segment_digest, + segment_size_bytes, + page_count, + } => { + if state.evidence.len() as u64 != segment_size_bytes + || state.evidence_pages != page_count + || ManifestDigest::from_sha256_bytes(&Sha256::digest(&state.evidence)) + .map_err(|error| Self::unavailable(&error.to_string()))? + != *segment_digest + { + return Err(Self::unavailable("staged evidence pack does not verify")); + } + let bytes = Arc::from(std::mem::take(&mut state.evidence)); + state.segments.insert(segment_digest.clone(), bytes); + state.evidence_pages = 0; + } + SealedGenerationSegmentPublicationV1::File { digest, bytes } + | SealedGenerationSegmentPublicationV1::FileEvidence { digest, bytes } + | SealedGenerationSegmentPublicationV1::ResolutionIndex { digest, bytes } + | SealedGenerationSegmentPublicationV1::CodeGraphPage { + page_digest: digest, + bytes, + .. + } => { + if ManifestDigest::from_sha256_bytes(&Sha256::digest(bytes)) + .map_err(|error| Self::unavailable(&error.to_string()))? + != *digest + { + return Err(Self::unavailable( + "staged segment content address does not verify", + )); + } + state.segments.insert(digest.clone(), Arc::from(bytes)); + } + } + Ok(()) + } + + fn segment_reader(&self) -> Arc { + let staged = Arc::clone(&self.state); + let incumbent = self.store.segment_reader(); + Arc::new(move |request, buffer| { + let (digest, size, range) = match request { + SealedGenerationSegmentReadV1::Whole { digest, size_bytes } => { + (digest, size_bytes, 0..size_bytes) + } + SealedGenerationSegmentReadV1::Range { + digest, + size_bytes, + offset, + length, + } => { + let end = offset.checked_add(length).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "staged segment range overflowed".to_owned(), + ) + })?; + (digest, size_bytes, offset..end) + } + }; + let bytes = { + let state = staged.lock().unwrap_or_else(PoisonError::into_inner); + if state.closed && !state.committed { + return Err(CodeIndexProductionErrorV1::Contract( + "publication preparation is closed".to_owned(), + )); + } + state.segments.get(digest).map(Arc::clone) + }; + let Some(bytes) = bytes else { + return incumbent(request, buffer); + }; + let slice = usize::try_from(range.start) + .ok() + .zip(usize::try_from(range.end).ok()) + .filter(|_| size == bytes.len() as u64) + .and_then(|(start, end)| bytes.get(start..end)) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "staged segment extent does not verify".to_owned(), + ) + })?; + buffer.clear(); + buffer.extend_from_slice(slice); + Ok(()) + }) + } + + fn abort(mut self) -> Result<(), CodeIndexPublicationStoreErrorV1> { + self.close(); + Ok(()) } } impl CodeIndexAtomicPublicationPort for MemorySealedPublicationStoreV1 { + type Preparation = MemoryCodeIndexPreparationV1; + fn load_active( &self, scope: &CodeIndexGenerationScopeV1, @@ -198,34 +321,174 @@ impl CodeIndexAtomicPublicationPort for MemorySealedPublicationStoreV1 { .map(|manifest| CodeIndexSealedGenerationV1::new(manifest, self.segment_reader()))) } - fn publish_atomically( + fn prepare( &mut self, scope: &CodeIndexGenerationScopeV1, expected_active_generation: Option<&CodeGenerationId>, - generation: &CodeIndexSealedPublicationV1, - ) -> Result, CodeIndexPublicationStoreErrorV1> { - let incumbent = || { - self.state() - .active - .get(scope) - .map(|(generation, _)| generation.clone()) - }; - if incumbent().as_ref() != expected_active_generation { + ) -> Result { + if self + .state() + .active + .get(scope) + .map(|(generation, _)| generation) + != expected_active_generation + { return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); } - let manifest = self.seal(generation)?; + Ok(MemoryCodeIndexPreparationV1 { + store: self.clone(), + scope: scope.clone(), + expected: expected_active_generation.cloned(), + state: Arc::new(Mutex::new(MemoryPreparationStateV1::default())), + }) + } + + fn commit( + &mut self, + preparation: &mut Self::Preparation, + publication: &CodeIndexSealedPublicationV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + if !self.shares_state_with(&preparation.store) + || preparation.scope != CodeIndexGenerationScopeV1::for_snapshot(publication.snapshot()) + { + return Err(MemoryCodeIndexPreparationV1::unavailable( + "preparation belongs to another publication store or scope", + )); + } + let mut prepared = preparation + .state + .lock() + .unwrap_or_else(PoisonError::into_inner); + if prepared.closed || !prepared.evidence.is_empty() || prepared.evidence_pages != 0 { + return Err(MemoryCodeIndexPreparationV1::unavailable( + "preparation is closed or its evidence pack is incomplete", + )); + } let mut state = self.state(); - if state.active.get(scope).map(|(generation, _)| generation) != expected_active_generation { + if state + .active + .get(&preparation.scope) + .map(|(generation, _)| generation) + != preparation.expected.as_ref() + { return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); } + let required = CodeIndexPublishedGenerationV1::partitioned_segment_identities( + publication.manifest_bytes(), + ) + .map_err(|error| MemoryCodeIndexPreparationV1::unavailable(&error.to_string()))?; + for identity in required { + let bytes = prepared.segments.get(&identity.digest) + .or_else(|| state.segments.get(&identity.digest)) + .ok_or_else(|| MemoryCodeIndexPreparationV1::unavailable( + "prepared publication references a segment absent from its preparation and store"))?; + if u64::try_from(bytes.len()).ok() != Some(identity.size_bytes) { + return Err(MemoryCodeIndexPreparationV1::unavailable( + "prepared publication segment extent does not match its manifest", + )); + } + } + state.segments.extend(prepared.segments.drain()); + let manifest = Arc::clone(publication.manifest_bytes()); + let generation = publication.manifest().generation_id.clone(); + state + .manifests + .insert(generation.clone(), Arc::clone(&manifest)); state.active.insert( - scope.clone(), - ( - generation.manifest().generation_id.clone(), - Arc::clone(&manifest), - ), + preparation.scope.clone(), + (generation, Arc::clone(&manifest)), ); state.publications = state.publications.saturating_add(1); + prepared.closed = true; + prepared.committed = true; Ok(manifest) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::production::worker_tests::{ + decoded_generation, worker_config, worker_request_with_source, + }; + use crate::production::{CodeIndexProductionOwnerV1, UninterruptibleCodeIndexControlV1}; + + fn decoded_fixture() -> Arc { + let mut owner = CodeIndexProductionOwnerV1::new( + worker_config(), + MemorySealedPublicationStoreV1::default(), + ) + .expect("source owner"); + let built = owner + .build_and_publish( + worker_request_with_source( + "file.preparation", + 1_100_000, + b"pub fn prepared() {}\n", + ), + &UninterruptibleCodeIndexControlV1, + ) + .expect("source publication"); + decoded_generation(&built) + } + + #[test] + fn an_unstaged_preparation_cannot_activate_another_preparations_publication() { + let generation = decoded_fixture(); + let scope = generation.sealed_scope(); + let mut store = MemorySealedPublicationStoreV1::default(); + let mut staged = store.prepare(&scope, None).expect("staged preparation"); + let publication = generation + .stage_publication(&mut staged) + .expect("stage generation"); + let mut empty = store + .prepare(&scope, None) + .expect("independent preparation"); + assert!(store.commit(&mut empty, &publication).is_err()); + assert_eq!(store.publications(), 0); + assert!(store.load_active(&scope).expect("active slot").is_none()); + store + .commit(&mut staged, &publication) + .expect("the complete preparation commits"); + assert_eq!(store.publications(), 1); + store + .decode_active(&scope) + .expect("committed segments decode") + .expect("active generation"); + } + + #[test] + fn readers_lose_aborted_staging_and_keep_successfully_committed_segments() { + let generation = decoded_fixture(); + let scope = generation.sealed_scope(); + let mut store = MemorySealedPublicationStoreV1::default(); + let mut aborted = store.prepare(&scope, None).expect("aborted preparation"); + let publication = generation + .stage_publication(&mut aborted) + .expect("stage generation"); + let identity = CodeIndexPublishedGenerationV1::partitioned_segment_identities( + publication.manifest_bytes(), + ) + .expect("segment identities") + .remove(0); + let request = SealedGenerationSegmentReadV1::Whole { + digest: &identity.digest, + size_bytes: identity.size_bytes, + }; + let reader = aborted.segment_reader(); + let mut bytes = Vec::new(); + reader(request, &mut bytes).expect("open staging is readable"); + aborted.abort().expect("abort"); + assert!(reader(request, &mut bytes).is_err()); + + let mut committed = store.prepare(&scope, None).expect("committed preparation"); + let publication = generation + .stage_publication(&mut committed) + .expect("stage generation"); + let reader = committed.segment_reader(); + store.commit(&mut committed, &publication).expect("commit"); + drop(committed); + reader(request, &mut bytes).expect("committed reader outlives preparation"); + assert_eq!(bytes.len() as u64, identity.size_bytes); + } +} diff --git a/crates/tracedecay-code-index/src/production/mod.rs b/crates/tracedecay-code-index/src/production/mod.rs index e820959115..000a524600 100644 --- a/crates/tracedecay-code-index/src/production/mod.rs +++ b/crates/tracedecay-code-index/src/production/mod.rs @@ -7,18 +7,24 @@ use std::{ use rayon::prelude::*; use serde::{Deserialize, Serialize}; +use std::num::NonZeroU64; use thiserror::Error; use tracedecay_code_extraction::ExtractedSchemaEvidenceV1; use tracedecay_code_extraction::incremental::{ParseDocumentIdentity, ParseError}; use tracedecay_domain::{ - CanonicalRelationEdgeV1, CodeGenerationId, CodeGenerationManifestV1, - CodeGenerationSourceCommitmentsV1, CodeIndexCapabilityManifestV1, ComponentVersion, - CoverageSummaryV1, ExtractorRevision, FileOccurrenceId, GenerationTestAttributionV1, - ManifestDigest, PolicyRevisionId, PrivacyDomainId, ProjectId, ProjectionBatchReceiptV1, - ProjectionBatchRequestV1, ProjectionKeyV1, ProjectionReplayReasonV1, ProviderEvaluationStateV1, - RefId, RelationEdgeKindV1, RepositoryId, SanitizedCodeFileV1, SanitizedCodeSnapshotV1, - SanitizerRevision, SensitivityLevelV1, SnapshotFileDispositionV1, SymbolOccurrenceId, - TestAttributionEvidenceClassV1, UtcMicros, ValidatedCodeFileV1, WorktreeId, canonical_sha256, + CanonicalRelationEdgeV1, ChangedCodeChunkSetV1, ChangedCodeChunkV1, CodeGenerationId, + CodeGenerationManifestV1, CodeGenerationSourceCommitmentsV1, CodeIndexCapabilityManifestV1, + ComponentVersion, CoverageSummaryV1, ExtractorRevision, FileOccurrenceId, + GenerationTestAttributionV1, ManifestDigest, PolicyRevisionId, PrivacyDomainId, ProjectId, + ProjectionBatchReceiptV1, ProjectionBatchRequestV1, ProjectionKeyV1, ProjectionReplayReasonV1, + ProviderEvaluationStateV1, RefId, RelationEdgeKindV1, RepositoryId, SanitizedCodeFileV1, + SanitizedCodeSnapshotV1, SanitizerRevision, SensitivityLevelV1, SnapshotFileDispositionV1, + SymbolOccurrenceId, TestAttributionEvidenceClassV1, UtcMicros, ValidatedCodeFileV1, WorktreeId, + canonical_sha256, +}; +use tracedecay_runtime_core::resident_memory::{ + ProcessSharedMemoryReservationV1, ResidentMemoryComponentIdV1, ResidentMemoryOperationV1, + ResidentMemoryReconstructionDemandV1, }; use super::{ @@ -33,8 +39,8 @@ use super::{ }, clones::{ClonePayloadBuildStatsV1, CodeIndexCloneBodyV1}, extract::{ExtractionCancellation, TreeSitterExtractor, rebind_extraction_batch}, - generations::{GenerationPlanner, GenerationPlanningErrorV1}, - incremental::{ChunkIncrementErrorV1, GenerationChunkManifestV1, plan_chunk_increment}, + generations::{GenerationPlanner, GenerationPlanningErrorV1, placeholder_digest}, + incremental::{ChunkIncrementErrorV1, GenerationChunkManifestV1}, intake::{ CodeIndexIntake, ReceiptBoundCodeFileAuthorityV1, ReceiptBoundCodeFileV1, SanitizedCodeIntake, SanitizedSnapshotCapabilityV1, @@ -104,13 +110,13 @@ pub(crate) use graph_build_bound::{layered_page_graph_build_bound, sealed_page_g mod graph_page_store; mod graph_pages; mod memory_store; -pub use memory_store::MemorySealedPublicationStoreV1; +pub use memory_store::{MemoryCodeIndexPreparationV1, MemorySealedPublicationStoreV1}; mod sealed_parent; use sealed_parent::SealedParentGenerationV1; pub use sealed_parent::{CodeIndexSealedGenerationV1, SealedSegmentReaderV1}; mod sparse_increment; +pub use sparse_increment::CodeIndexColdBuildReasonV1; use sparse_increment::SparseBuildV1; -pub use sparse_increment::{CodeIndexColdBuildReasonV1, CodeIndexSparseGenerationV1}; mod sparse_resolution; mod sparse_successor; #[cfg(test)] @@ -373,7 +379,8 @@ impl CodeIndexGenerationScopeV1 { self.repository == other.repository && self.worktree == other.worktree } - fn validate(&self) -> Result<(), CodeIndexPublicationStoreErrorV1> { + /// Validate every component before a publication store binds this slot. + pub fn validate(&self) -> Result<(), CodeIndexPublicationStoreErrorV1> { self.repository .validate() .and_then(|()| self.reference.as_ref().map_or(Ok(()), RefId::validate)) @@ -406,117 +413,343 @@ fn describe_scope(scope: &CodeIndexGenerationScopeV1) -> String { /// complete generation and verified projection receipt visible as one scoped /// compare-and-swap operation and return the same immutable value on restart. pub trait CodeIndexAtomicPublicationPort { - /// The scope's active sealed generation. A generation sealed in a - /// revision this build no longer reads is no active generation to build - /// over; the store keeps its own compare-and-swap token for it. + type Preparation: CodeIndexPreparationPort + Send + 'static; + fn load_active( &self, scope: &CodeIndexGenerationScopeV1, ) -> Result, CodeIndexPublicationStoreErrorV1>; - /// Seal `generation`'s segments and manifest and make it the scope's - /// active generation in one compare-and-swap, returning the manifest - /// bytes it sealed. - fn publish_atomically( + /// Stage an unpublished successor without excluding active readers. The + /// returned owner binds this physical store, scope, and exact incumbent. + fn prepare( &mut self, scope: &CodeIndexGenerationScopeV1, expected_active_generation: Option<&CodeGenerationId>, - generation: &CodeIndexSealedPublicationV1, + ) -> Result; + + /// Recheck source and incumbent fences, make staged segments durable, and + /// activate the exact prepared manifest in one publication transaction. + /// A retryable failure leaves the preparation owned by the caller. + fn commit( + &mut self, + preparation: &mut Self::Preparation, + publication: &CodeIndexSealedPublicationV1, ) -> Result, CodeIndexPublicationStoreErrorV1>; } -/// One generation ready for a publication store to seal. +/// The canonical store owns unpublished segment bytes until commit or abort. +/// Readers can resolve a staged segment before its final durable name exists; +/// abort invalidates those readers and removes only this owner's temporaries. +pub trait CodeIndexPreparationPort { + fn write_segment( + &mut self, + publication: SealedGenerationSegmentPublicationV1<'_>, + ) -> Result<(), CodeIndexPublicationStoreErrorV1>; + + fn segment_reader(&self) -> Arc; + + fn abort(self) -> Result<(), CodeIndexPublicationStoreErrorV1>; +} + +#[derive(Debug)] +struct ConstructionMemoryV1 { + operation: ResidentMemoryOperationV1, + component: ResidentMemoryComponentIdV1, + charge: Option, + charged_bytes: u64, +} + +impl ConstructionMemoryV1 { + fn new(operation: ResidentMemoryOperationV1) -> Result { + let component = ResidentMemoryComponentIdV1::new("code_index.construction.v1") + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; + Ok(Self { + operation, + component, + charge: None, + charged_bytes: 0, + }) + } + + fn resize(&mut self, bytes: u64) -> Result<(), CodeIndexProductionErrorV1> { + if bytes > self.charged_bytes { + let additional = NonZeroU64::new(bytes - self.charged_bytes).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("construction charge overflowed".to_owned()) + })?; + let retained_bytes = self.charged_bytes; + let refused = |error: tracedecay_runtime_core::resident_memory::ResidentMemoryAdmissionFailureV1| { + tracing::trace!(prior_charged_bytes = retained_bytes, attempted_bytes = bytes, + additional_bytes = additional.get(), %error, + "construction admission refused"); + CodeIndexProductionErrorV1::Publication( + CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused(error.to_string()), + ) + }; + match self.charge.as_mut() { + Some(charge) => charge.try_grow(additional).map_err(refused)?, + None => { + self.charge = Some( + self.operation + .reserve_process_shared(self.component, additional) + .map_err(refused)?, + ) + } + } + } else if bytes == 0 { + drop(self.charge.take()); + } else if let Some(charge) = self.charge.as_mut() { + charge + .shrink_to(bytes) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; + } else { + return Err(CodeIndexProductionErrorV1::Contract( + "construction charge ownership is inconsistent".to_owned(), + )); + } + self.charged_bytes = bytes; + Ok(()) + } +} + +/// A canonical hash keeps its per-thread scratch until explicitly released. +/// Pair its real lifetime with the construction reservation on every exit. +struct ConstructionCanonicalScratchV1; + +impl Drop for ConstructionCanonicalScratchV1 { + fn drop(&mut self) { + tracedecay_domain::research::release_thread_canonical_scratch(); + } +} + +impl ConstructionMemoryV1 { + fn canonical( + &mut self, + base: usize, + objects: usize, + work: impl FnOnce() -> Result, + ) -> Result { + tracedecay_domain::research::release_thread_canonical_scratch(); + // Canonical SHA's 64 KiB sink and the eight-slot object-buffer pool + // coexist with the known DTO ObjectWriter allocations below. + let pool = 8_usize.saturating_mul(std::mem::size_of::<( + String, + Vec<(std::borrow::Cow<'static, str>, std::ops::Range)>, + )>()); + self.resize( + base.saturating_add(objects) + .saturating_add(64 * 1024) + .saturating_add(pool) as u64, + )?; + let _scratch = ConstructionCanonicalScratchV1; + work() + } +} + +fn changed_set_hash_workspace( + changes: &ChangedCodeChunkSetV1, +) -> Result { + let row = changes + .added_or_changed + .iter() + .chain(&changes.deleted) + .try_fold(0_usize, |largest, row| { + partitioned_codec::encoded_json_rows_length(row).map(|bytes| largest.max(bytes)) + })?; + // The changed-set and both digest DTOs stream their checked, ordered + // fields. Only one changed-row object is buffered at a time. + let domains = partitioned_codec::encoded_json_rows_length(&( + "tracedecay.changed-code-chunk-set.v1", + "tracedecay.code-source-parent-delta.v1", + &changes.from_generation, + ))?; + Ok( + partitioned_codec::canonical_object_workspace_bytes(domains, 8) + .saturating_add(partitioned_codec::canonical_object_workspace_bytes(row, 3)) + .saturating_add(resident_bytes::btree_bytes( + changes + .added_or_changed + .len() + .saturating_add(changes.deleted.len()), + std::mem::size_of::<&tracedecay_domain::CodeSearchChunkId>(), + )), + ) +} + +fn manifest_hash_workspace( + manifest: &CodeGenerationManifestV1, +) -> Result { + Ok(partitioned_codec::canonical_object_workspace_bytes( + partitioned_codec::encoded_json_rows_length(manifest)?, + 16, + ) + .saturating_add(partitioned_codec::canonical_object_workspace_bytes( + partitioned_codec::encoded_json_rows_length(&manifest.source_commitments)?, + 3, + ))) +} + +fn metadata_hash_workspace( + manifest: &CodeGenerationManifestV1, + snapshot: &SanitizedCodeSnapshotV1, +) -> Result { + let snapshot_bytes = partitioned_codec::encoded_json_rows_length(snapshot)?; + let mut file = 0_usize; + for row in &snapshot.files { + file = file.max(partitioned_codec::encoded_json_rows_length(row)?); + } + let mut omission = 0_usize; + for row in &snapshot.omitted_sources { + omission = omission.max(partitioned_codec::encoded_json_rows_length(row)?); + } + Ok( + partitioned_codec::canonical_object_workspace_bytes(snapshot_bytes, 10) + .saturating_add(partitioned_codec::canonical_object_workspace_bytes(file, 6)) + // An omission has a nested reason object in addition to its own row. + .saturating_add( + partitioned_codec::canonical_object_workspace_bytes(omission, 2).saturating_mul(2), + ) + .saturating_add(manifest_hash_workspace(manifest)?), + ) +} + +fn emit_cold_capability( + memory: &mut ConstructionMemoryV1, + base: usize, + intake: &SanitizedCodeIntake, + manifest: &CodeGenerationManifestV1, + snapshot: &SanitizedCodeSnapshotV1, + coverage: CoverageSummaryV1, +) -> Result { + // Intake already owns the exact snapshot-scoped registry. Rebuilding the + // complete compiled registry here would allocate unrelated descriptors. + let handles = manifest + .grammar_revisions + .len() + .saturating_mul(std::mem::size_of::<&tracedecay_domain::LanguageDescriptorV1>()); + memory.resize(base.saturating_add(handles) as u64)?; + let descriptors = intake.registry().descriptors(); + let workspace = + resident_bytes::capability_construction_workspace(&descriptors, snapshot, manifest); + let length = partitioned_codec::encoded_json_rows_length(&( + manifest, + &descriptors, + &snapshot.sanitization_receipts, + crate::capabilities::BASE_EXACT_TERM_KINDS, + crate::capabilities::BASE_EDGE_AUTHORITY_CLASSES, + tracedecay_domain::CodeSearchChunkGrainV1::ORDER, + ))?; + let descriptor_json = descriptors + .iter() + .try_fold(0_usize, |largest, descriptor| { + partitioned_codec::encoded_json_rows_length(descriptor).map(|bytes| largest.max(bytes)) + })?; + let hash_workspace = partitioned_codec::canonical_object_workspace_bytes(length, 13) + .saturating_add(partitioned_codec::canonical_object_workspace_bytes( + descriptor_json, + 12, + )) + .saturating_add(partitioned_codec::canonical_object_workspace_bytes( + partitioned_codec::encoded_json_rows_length(&coverage)?, + 8, + )); + memory.canonical( + base.saturating_add(handles).saturating_add(workspace), + hash_workspace, + || { + let registry = StaticLanguageRegistry::try_from_descriptors( + descriptors + .iter() + .map(|descriptor| (**descriptor).clone()) + .collect(), + ) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; + BaseCapabilityEmitter::new(registry, coverage, snapshot.sanitization_receipts.clone()) + .emit(manifest) + .map_err(CodeIndexProductionErrorV1::Capability) + }, + ) +} + +/// Compact, verified output of construction. File and evidence payloads belong +/// to the store preparation rather than a decoded generation kept until CAS. #[derive(Clone, Debug)] -pub enum CodeIndexSealedPublicationV1 { - /// A generation built whole in memory, and why; sealing encodes every - /// segment a parent does not already store. - Cold( - Arc, - CodeIndexColdBuildReasonV1, - ), - /// A successor built over its sealed parent; its new segments and its - /// manifest are already encoded. - Sparse(Arc), +pub struct CodeIndexSealedPublicationV1 { + metadata: Arc, + manifest_bytes: Arc<[u8]>, + lane_digest: ManifestDigest, + chunk_count: u64, + projection: ProjectionPublicationHandoffV1, + cold_reason: Option, + clone_payloads_reused: u64, + clone_payloads_computed: u64, + clone_stale_invalidations: u64, + construction_memory: Option>, } impl CodeIndexSealedPublicationV1 { - pub fn manifest(&self) -> &CodeGenerationManifestV1 { - match self { - Self::Cold(generation, _) => generation.manifest(), - Self::Sparse(generation) => generation.manifest(), + pub(super) fn from_prepared( + manifest_bytes: Arc<[u8]>, + projection: ProjectionPublicationHandoffV1, + cold_reason: Option, + clone_stats: (u64, u64, u64), + ) -> Result { + let (metadata, lane_digest, chunk_count) = + CodeIndexPublishedGenerationV1::partitioned_metadata_and_lane(&manifest_bytes)?; + let request = projection.request(); + let expected_chunks = u64::try_from(request.changes.added_or_changed.len()) + .ok() + .and_then(|changed| changed.checked_add(request.changes.reused_count)); + if request.changes.to_generation != metadata.manifest().generation_id + || request.changes.manifest_digest + != metadata.source_commitments()?.incremental_manifest_digest + || expected_chunks != Some(chunk_count) + { + return Err(CodeIndexProductionErrorV1::Contract( + "prepared publication does not match its verified projection".to_owned(), + )); } + Ok(Self { + metadata: Arc::new(metadata), + manifest_bytes, + lane_digest, + chunk_count, + projection, + cold_reason, + clone_payloads_reused: clone_stats.0, + clone_payloads_computed: clone_stats.1, + clone_stale_invalidations: clone_stats.2, + construction_memory: None, + }) } + pub fn manifest(&self) -> &CodeGenerationManifestV1 { + self.metadata.manifest() + } pub fn snapshot(&self) -> &SanitizedCodeSnapshotV1 { - match self { - Self::Cold(generation, _) => generation.snapshot(), - Self::Sparse(generation) => generation.snapshot(), - } + self.metadata.snapshot() } - pub fn projection(&self) -> &ProjectionPublicationHandoffV1 { - match self { - Self::Cold(generation, _) => generation.projection(), - Self::Sparse(generation) => generation.projection(), - } + &self.projection } - pub fn statistics(&self) -> &CodeIndexGenerationStatisticsV1 { - match self { - Self::Cold(generation, _) => &generation.statistics, - Self::Sparse(generation) => generation.statistics(), - } + self.metadata.generation_statistics() } - pub fn chunk_count(&self) -> u64 { - match self { - Self::Cold(generation, _) => { - u64::try_from(generation.chunks().chunks().len()).unwrap_or(u64::MAX) - } - Self::Sparse(generation) => generation.chunk_count(), - } + self.chunk_count } - - /// The decoded generation a cold build holds; a sparse successor has - /// none. - pub fn decoded(&self) -> Option<&Arc> { - match self { - Self::Cold(generation, _) => Some(generation), - Self::Sparse(_) => None, - } + pub fn manifest_bytes(&self) -> &Arc<[u8]> { + &self.manifest_bytes } - pub fn clone_update_statistics(&self) -> (u64, u64, bool) { - match self { - Self::Cold(generation, _) => generation.clone_update_statistics(), - Self::Sparse(generation) => generation.clone_update_statistics(), - } + ( + self.clone_payloads_reused, + self.clone_stale_invalidations, + self.clone_payloads_computed > 0 || self.clone_stale_invalidations > 0, + ) } - - /// Why the build ran whole; `None` for a sparse successor. pub fn cold_reason(&self) -> Option { - match self { - Self::Cold(_, reason) => Some(*reason), - Self::Sparse(_) => None, - } - } - - /// Publish every segment the store does not already hold through - /// `publish_segment` and return the manifest naming them. - /// `parent_manifest_bytes` is the manifest of the generation this one - /// names as its parent, when the store holds it. - pub fn encode( - &self, - parent_manifest_bytes: Option<&[u8]>, - publish_segment: impl FnMut( - SealedGenerationSegmentPublicationV1<'_>, - ) -> Result<(), CodeIndexProductionErrorV1>, - ) -> Result, CodeIndexProductionErrorV1> { - match self { - Self::Cold(generation, _) => generation - .encode_partitioned_sealed_with_parent(parent_manifest_bytes, publish_segment), - Self::Sparse(generation) => generation.replay(publish_segment), - } + self.cold_reason } } @@ -528,6 +761,7 @@ pub struct CodeIndexPublishedBuildV1 { metadata: Arc, manifest_bytes: Arc<[u8]>, lane_digest: ManifestDigest, + sealed: CodeIndexSealedGenerationV1, } impl CodeIndexPublishedBuildV1 { @@ -535,23 +769,27 @@ impl CodeIndexPublishedBuildV1 { /// `publication` as `manifest_bytes`. pub fn new( publication: CodeIndexSealedPublicationV1, - manifest_bytes: Arc<[u8]>, + sealed: CodeIndexSealedGenerationV1, ) -> Result { - let (metadata, lane_digest) = - CodeIndexPublishedGenerationV1::partitioned_metadata_and_lane(&manifest_bytes)?; - if metadata.manifest() != publication.manifest() { + let manifest_bytes: Arc<[u8]> = Arc::from(sealed.manifest_bytes()); + if publication.manifest_bytes.as_ref() != manifest_bytes.as_ref() { return Err(CodeIndexProductionErrorV1::Contract( "the publication store sealed a different generation".to_owned(), )); } Ok(Self { - publication, - metadata: Arc::new(metadata), + metadata: Arc::clone(&publication.metadata), manifest_bytes, - lane_digest, + lane_digest: publication.lane_digest.clone(), + publication, + sealed, }) } + pub fn sealed_generation(&self) -> &CodeIndexSealedGenerationV1 { + &self.sealed + } + pub fn manifest(&self) -> &CodeGenerationManifestV1 { self.publication.manifest() } @@ -578,10 +816,6 @@ impl CodeIndexPublishedBuildV1 { &self.lane_digest } - pub fn decoded(&self) -> Option<&Arc> { - self.publication.decoded() - } - pub fn publication(&self) -> &CodeIndexSealedPublicationV1 { &self.publication } @@ -1001,6 +1235,25 @@ impl ChunkPolicyRevisionSummaryV1 { } impl CodeIndexPublishedGenerationV1 { + /// Stage a validated decoded or restored generation through the canonical + /// writer. The caller retains its decoded input; the returned publication + /// owns only authenticated metadata and its projection proof. This does + /// not activate the generation or bypass the writer's final source/CAS fence. + pub fn stage_publication( + &self, + preparation: &mut impl CodeIndexPreparationPort, + ) -> Result { + let manifest = self.encode_partitioned_sealed(|segment| { + preparation.write_segment(segment).map_err(Into::into) + })?; + CodeIndexSealedPublicationV1::from_prepared( + Arc::from(manifest), + self.projection.clone(), + None, + (0, 0, 0), + ) + } + pub fn manifest(&self) -> &CodeGenerationManifestV1 { &self.manifest } @@ -1055,10 +1308,12 @@ impl CodeIndexPublishedGenerationV1 { /// Call sites whose import binding names project code the seal could not /// bind; see [`helpers::unresolved_import_calls`]. - pub fn unresolved_import_calls(&self) -> Vec { + pub fn unresolved_import_calls( + &self, + ) -> Result, CodeIndexProductionErrorV1> { unresolved_import_calls( &self.files, - &resolution_view::FileSymbolsByNameV1::new(&self.files), + &resolution_view::FileSymbolsByNameV1::new(&self.files)?, None, ) } @@ -1559,25 +1814,12 @@ impl CodeIndexPublishedGenerationV1 { /// Use this wherever bytes were genuinely re-read (sealed-generation /// restore) so the memoized fast path can never mask a real re-read. pub(crate) fn validate_fresh(&self) -> Result<(), CodeIndexProductionErrorV1> { - self.validate_uncached(true)?; - let _ = self.validated.set(()); - Ok(()) - } - - /// Validate a generation this build assembled in memory. - /// - /// Skips re-deriving what this build derived moments ago from the same - /// immutable values: per-file artifact checks (each page was validated - /// where extraction produced it) and the source commitments, which - /// `build_and_publish` computes from this projection's change set. - /// Generation-level structure and the corpus complement proof still run. - pub(crate) fn validate_fresh_built(&self) -> Result<(), CodeIndexProductionErrorV1> { - self.validate_uncached(false)?; + self.validate_uncached()?; let _ = self.validated.set(()); Ok(()) } - fn validate_uncached(&self, reread: bool) -> Result<(), CodeIndexProductionErrorV1> { + fn validate_uncached(&self) -> Result<(), CodeIndexProductionErrorV1> { self.manifest .validate() .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; @@ -1619,7 +1861,7 @@ impl CodeIndexPublishedGenerationV1 { &full_source, ) .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - if reread { + { commitments .validate_for_changes(&self.projection.request().changes) .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; @@ -1665,7 +1907,7 @@ impl CodeIndexPublishedGenerationV1 { { let _span = tracing::trace_span!("code_index.collect.validate_files").entered(); collect_bounded_ordered(&files, |file| { - if reread { + { file.artifacts .validate() .map_err(CodeIndexProductionErrorV1::Chunk)?; @@ -1756,7 +1998,7 @@ impl CodeIndexPublishedGenerationV1 { } } // Edges are never persisted: every generation, restored or freshly - // built, owns the vector `collect_edge_evidence` just derived from + // built, owns the vector canonical resolution derived from // these same immutable files. Re-deriving it here would compare a // deterministic function against itself at the price of a second // 1.7M-reference resolution, so the persisted per-file abstentions @@ -1868,9 +2110,11 @@ struct ActiveGenerationLookupV1 { } /// Production owner for one repository and one atomic publication authority. -pub struct CodeIndexProductionOwnerV1

{ +pub struct CodeIndexProductionOwnerV1 { config: CodeIndexProductionConfigV1, publication: P, + unpublished: Option<(P::Preparation, CodeIndexSealedPublicationV1)>, + reconstruction_requirement: Option, physical_artifacts: SharedPhysicalCodeArtifactPoolV1, retained_parses: SharedRetainedParsePool, } @@ -1887,6 +2131,8 @@ where Ok(Self { config, publication, + unpublished: None, + reconstruction_requirement: None, physical_artifacts: SharedPhysicalCodeArtifactPoolV1::default(), retained_parses: SharedRetainedParsePool::default(), }) @@ -1905,6 +2151,12 @@ where self } + /// Rebind attempt-scoped publication fences on the existing physical + /// store handle while retaining this owner's parser and artifact pools. + pub fn publication_mut(&mut self) -> &mut P { + &mut self.publication + } + pub fn retained_parse_stats(&self) -> RetainedParsePoolStats { self.retained_parses.stats() } @@ -2015,6 +2267,36 @@ where &mut self, request: CodeIndexBuildRequestV1, control: &dyn CodeIndexExecutionControlV1, + ) -> Result { + // Starting construction supersedes any unclaimed commit retry. A + // caller retaining that work takes the owned pair instead. + drop(self.unpublished.take()); + let operation = request.resident_memory.begin_operation(); + let ceiling = request.resident_memory.limit_bytes(); + self.reconstruction_requirement = None; + let result = self.build_in_operation(request, control, &operation); + if result.is_err() { + self.reconstruction_requirement = operation + .witnessed_reconstruction_requirement(ceiling) + .map_err(|failure| CodeIndexProductionErrorV1::Contract(failure.to_string()))?; + } + result + } + + /// The construction requirement witnessed before its regions unwind. + /// Callers apply it only when discarding construction; retaining the + /// preparation retries commit without reacquiring these bytes. + pub fn take_reconstruction_requirement( + &mut self, + ) -> Option { + self.reconstruction_requirement.take() + } + + fn build_in_operation( + &mut self, + request: CodeIndexBuildRequestV1, + control: &dyn CodeIndexExecutionControlV1, + operation: &ResidentMemoryOperationV1, ) -> Result { crate::observe::record_generation_state("building"); crate::observe::record_rebuild_state("unknown"); @@ -2104,6 +2386,9 @@ where } else { "initial" }); + let mut preparation = self + .publication + .prepare(&scope, lookup.cas_incumbent.as_ref())?; let cold_reason = match (parent.as_ref(), increment.as_ref()) { (Some(parent), Some(plan)) => { let sparse = SparseBuildV1 { @@ -2118,17 +2403,21 @@ where ignored_source_roster: &ignored_source_roster, captured_files: &captured_files, target_projection_key: &request.target_projection_key, + construction_operation: operation, control, } - .build(parent, plan, manifest.clone(), &validated.snapshot)?; + .build( + parent, + plan, + manifest.clone(), + &validated.snapshot, + &mut preparation, + )?; match sparse { Ok(sparse) => { crate::observe::record_rebuild_state("increment"); - return self.publish( - &scope, - lookup.cas_incumbent.as_ref(), - CodeIndexSealedPublicationV1::Sparse(Arc::new(sparse)), - ); + lexical_page_source::checkpoint(control)?; + return self.publish(preparation, sparse); } Err(reason) => reason, } @@ -2137,169 +2426,61 @@ where }; crate::observe::record_rebuild_state("full"); lexical_page_source::checkpoint(control)?; - let staged = self.materialize_full( + let publication = self.materialize_cold( &intake, &capability, - &manifest, + manifest, &extractor, &chunker, - &request.repository_parse_identity, - &validated.snapshot, - parent.as_ref(), - &captured_files, - control, - )?; - lexical_page_source::checkpoint(control)?; - let candidate = self.assemble_cold( - manifest, - validated.snapshot, request.repository_parse_identity, ignored_source_roster, + &validated.snapshot, + parent.as_ref(), + captured_files, request.target_projection_key, - staged, + operation.clone(), + cold_reason, + &mut preparation, control, )?; - - self.publish( - &scope, - lookup.cas_incumbent.as_ref(), - CodeIndexSealedPublicationV1::Cold(Arc::new(candidate), cold_reason), - ) + lexical_page_source::checkpoint(control)?; + self.publish(preparation, publication) } - /// Assemble a generation built whole: every chunk replays as its initial - /// projection, and resolution runs over every file. - #[allow(clippy::too_many_arguments)] - fn assemble_cold( - &mut self, - mut manifest: CodeGenerationManifestV1, - snapshot: SanitizedCodeSnapshotV1, - repository_parse_identity: CodeIndexRepositoryParseIdentityV1, - ignored_source_roster: IgnoredSourceRosterV1, - target_projection_key: ProjectionKeyV1, - staged: StagedGenerationV1, - control: &dyn CodeIndexExecutionControlV1, - ) -> Result { - { - let _span = tracing::trace_span!("code_index.build.assemble").entered(); - { - let coverage = coverage_summary(&snapshot, &staged.files); - let changes = plan_chunk_increment(None, &staged.chunks) - .map_err(CodeIndexProductionErrorV1::Increment)?; - manifest.source_commitments = Some( - CodeGenerationSourceCommitmentsV1::from_changed_chunks(None, &changes) - .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?, - ); - manifest.seal.expected_digest = expected_seal_digest(&manifest) - .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - let capability = { - let _span = - tracing::trace_span!("code_index.build.assemble.capability").entered(); - { - BaseCapabilityEmitter::new( - registry_for_snapshot(&snapshot)?, - coverage, - snapshot.sanitization_receipts.clone(), - ) - .emit(&manifest) - .map_err(CodeIndexProductionErrorV1::Capability) - } - }?; - let projection = project_for_publication(projection_request( - None, - target_projection_key, - changes, - )?) - .map_err(CodeIndexProductionErrorV1::Projection)?; - lexical_page_source::checkpoint(control)?; - let imports = { - let _span = - tracing::trace_span!("code_index.build.assemble.import_evidence").entered(); - derive_import_evidence(&staged.files) - }; - let (edges, edge_abstentions, unresolved_calls, ambiguous_name_drops) = { - let _span = - tracing::trace_span!("code_index.build.assemble.graph_outputs").entered(); - { - let (edges, abstentions, gaps, ambiguous_name_drops) = - collect_edge_evidence(&staged.files)?; - let mut unresolved = resolution_outputs::unresolved_calls_for_edges( - &staged.files, - &edges, - &|| Ok(()), - )?; - unresolved.extend(gaps); - unresolved.sort(); - unresolved.dedup(); - Ok::<_, CodeIndexProductionErrorV1>(( - edges, - abstentions, - unresolved, - ambiguous_name_drops, - )) - } - }?; - let statistics = CodeIndexGenerationStatisticsV1::from_generation_parts( - &staged.files, - staged.symbols.symbols.len(), - edges.len(), - Some(ambiguous_name_drops), - )?; - let candidate = CodeIndexPublishedGenerationV1 { - manifest, - snapshot, - repository_parse_identity, - ignored_source_roster, - files: staged.files, - content: None, - chunks: staged.chunks, - symbols: staged.symbols, - lineage: Vec::new(), - imports, - edges, - unresolved_calls, - edge_abstentions, - statistics, - clone_payloads_reused: staged.clone_payloads_reused, - clone_payloads_computed: staged.clone_payloads_computed, - clone_stale_invalidations: staged.clone_stale_invalidations, - coverage, - capability, - projection, - validated: OnceLock::new(), - admitted: OnceLock::new(), - attribution: Arc::new(OnceLock::new()), - attribution_build: Arc::new(Mutex::new(ProviderEvaluationStateV1::Indexing)), - chunk_policy: OnceLock::new(), - retained_bytes: OnceLock::new(), - decode_peak_growth_bytes: None, - }; - { - let _span = - tracing::trace_span!("code_index.build.assemble.validate").entered(); - candidate.validate_fresh_built() - }?; - Ok(candidate) - } - } + /// A completed construction whose final durable commit can be retried. + /// The preparation and its authenticated publication always move together. + pub fn take_unpublished(&mut self) -> Option<(P::Preparation, CodeIndexSealedPublicationV1)> { + self.unpublished.take() } - /// Seal `publication` through the publication authority and describe - /// what it published. fn publish( &mut self, - scope: &CodeIndexGenerationScopeV1, - expected: Option<&CodeGenerationId>, + mut preparation: P::Preparation, publication: CodeIndexSealedPublicationV1, ) -> Result { let manifest_bytes = { let _span = tracing::trace_span!("code_index.build.publish").entered(); - self.publication - .publish_atomically(scope, expected, &publication) - }?; - let published = CodeIndexPublishedBuildV1::new(publication, manifest_bytes)?; - crate::observe::record_generation_state("queryable"); - Ok(published) + self.publication.commit(&mut preparation, &publication) + }; + match manifest_bytes { + Ok(bytes) => { + let sealed = CodeIndexSealedGenerationV1::new(bytes, preparation.segment_reader()); + let published = CodeIndexPublishedBuildV1::new(publication, sealed)?; + crate::observe::record_generation_state("queryable"); + Ok(published) + } + Err(error) => { + if matches!( + error, + CodeIndexPublicationStoreErrorV1::Unavailable(_) + | CodeIndexPublicationStoreErrorV1::StoreLockContended + | CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused(_) + ) { + self.unpublished = Some((preparation, publication)); + } + Err(error.into()) + } + } } fn intake_at( @@ -2318,62 +2499,551 @@ where } } + /// Stage each bounded extraction window before advancing. Full artifacts + /// never span windows; only authenticated chunk rows and binding facts do. #[allow(clippy::too_many_arguments)] #[tracing::instrument(name = "code_index.build.materialize_full", level = "trace", skip_all)] - fn materialize_full( + fn materialize_cold( &self, intake: &SanitizedCodeIntake, capability: &SanitizedSnapshotCapabilityV1, - manifest: &CodeGenerationManifestV1, + mut manifest: CodeGenerationManifestV1, extractor: &TreeSitterExtractor, chunker: &DeterministicCodeChunker, - repository_parse_identity: &CodeIndexRepositoryParseIdentityV1, + repository_parse_identity: CodeIndexRepositoryParseIdentityV1, + ignored_source_roster: IgnoredSourceRosterV1, snapshot: &SanitizedCodeSnapshotV1, parent: Option<&SealedParentGenerationV1>, - captured_files: &BTreeMap, + mut captured_files: BTreeMap, + target_projection_key: ProjectionKeyV1, + operation: ResidentMemoryOperationV1, + cold_reason: CodeIndexColdBuildReasonV1, + preparation: &mut P::Preparation, control: &dyn CodeIndexExecutionControlV1, - ) -> Result { - let present_files = snapshot + ) -> Result { + let mut memory = ConstructionMemoryV1::new(operation)?; + let present_count = snapshot .files .iter() .filter(|file| file.disposition == SnapshotFileDispositionV1::Present) - .collect::>(); + .count(); + let extraction_span = + tracing::trace_span!("code_index.cold.extraction", files = present_count,).entered(); + tracing::trace!( + phase = "extraction", + files = present_count, + "cold build phase started" + ); + let descriptor_bound = present_count + .saturating_mul( + std::mem::size_of::() + + 2 * 71, + ) + .saturating_add( + snapshot + .files + .iter() + .filter(|file| file.disposition == SnapshotFileDispositionV1::Present) + .map(|file| file.file_occurrence_id.as_str().len()) + .sum::(), + ); + let extraction_base = descriptor_bound + .saturating_add( + present_count.saturating_mul(std::mem::size_of::<(usize, &SanitizedCodeFileV1)>()), + ) + .saturating_add(manifest.project_id.as_str().len()) + .saturating_add(snapshot.repository.as_str().len()) + // Advancing the uniform policy census clones its one identity + // before the previous census value is dropped. + .saturating_add(self.config.policy_revision.as_str().len().saturating_mul(2)); + memory.resize(extraction_base as u64)?; + let mut present = Vec::with_capacity(present_count); + present.extend( + snapshot + .files + .iter() + .enumerate() + .filter(|(_, file)| file.disposition == SnapshotFileDispositionV1::Present), + ); + let mut file_segments = Vec::with_capacity(present_count); + let mut chunk_count = 0_usize; + let scope = sealed_codec::FileScopeIdentityV1::of(&manifest, snapshot); + let width = crate::parallelism::indexing_workers().max(1); + let mut chunk_policy = ChunkPolicyRevisionSummaryV1::Empty; + let mut clones_reused = 0_u64; + let mut clones_computed = 0_u64; + let worker_operation = memory.operation.clone(); let config = &self.config; let physical_artifacts = &self.physical_artifacts; let retained_parses = &self.retained_parses; - let extracted = { - let _span = tracing::trace_span!("code_index.collect.materialize_full").entered(); - collect_bounded_ordered(&present_files, |file| { - materialize_present_file( + for window in present.chunks(width) { + lexical_page_source::checkpoint(control)?; + let artifacts = collect_bounded_ordered(window, |(key, file)| { + let (reuse_key, artifact, clone_stats) = materialize_present_file( config, physical_artifacts, retained_parses, intake, capability, - manifest, + &manifest, extractor, chunker, - repository_parse_identity, + &repository_parse_identity, parent, file, - captured_files, + &captured_files, control, - ) - }) - }?; - // Parallel completion order is intentionally not cache authority. - // Record artifacts in canonical snapshot order so bounded eviction and - // subsequent physical reuse remain deterministic. - let mut files = Vec::with_capacity(extracted.len()); - for (reuse_key, artifact, _) in extracted { - lexical_page_source::checkpoint(control)?; - physical_artifacts.insert(reuse_key, &artifact); - files.push(artifact); + )?; + let key = u32::try_from(*key).map_err(|_| { + CodeIndexProductionErrorV1::Contract("snapshot file key exceeds u32".to_owned()) + })?; + let prior = parent.and_then(|parent| { + let descriptor = parent.file_segment(&file.logical_path)?; + let prior_file = parent + .generation() + .snapshot + .files + .get(descriptor.file_key as usize)?; + Some((parent.manifest(), prior_file, descriptor)) + }); + let mut identity_memory = ConstructionMemoryV1::new(worker_operation.clone())?; + let encoded = match partitioned_codec::reusable_file_segment( + &manifest, + file, + &artifact, + key, + prior, + &mut |bytes| identity_memory.resize(bytes), + )? { + Some(descriptor) => (descriptor, None), + None => { + let (descriptor, bytes) = partitioned_codec::encode_file_segment( + &manifest.generation_id, + &scope, + &artifact, + key, + )?; + (descriptor, Some(bytes)) + } + }; + Ok::<_, CodeIndexProductionErrorV1>((reuse_key, artifact, encoded, clone_stats)) + })?; + for (reuse_key, artifact, (descriptor, bytes), clone_stats) in artifacts { + clones_reused = clones_reused.saturating_add(clone_stats.reused); + clones_computed = clones_computed.saturating_add(clone_stats.computed); + lexical_page_source::checkpoint(control)?; + if let Some(bytes) = bytes { + preparation.write_segment(SealedGenerationSegmentPublicationV1::File { + digest: &descriptor.segment_digest, + bytes: &bytes, + })?; + } + chunk_count = chunk_count.saturating_add(artifact.artifacts.chunks.chunks.len()); + for chunk in &artifact.artifacts.chunks.chunks { + chunk_policy = + chunk_policy.with_chunks_under(&chunk.sensitivity.policy_revision); + } + file_segments.push(descriptor); + captured_files.remove(&artifact.extraction.file_occurrence_id); + self.physical_artifacts.insert(reuse_key, &artifact); + // Both cache authorities are weak: dropping this artifact is + // the final owner of its full extraction/chunk payload. + drop(artifact); + memory.resize(extraction_base as u64)?; + } } + drop(captured_files); + drop(present); + drop(scope); + let file_descriptor_bytes = resident_bytes::file_segment_descriptors_bytes( + &file_segments, + file_segments.capacity(), + ) + .saturating_add(resident_bytes::chunk_policy_heap_bytes(&chunk_policy)); + memory.resize(file_descriptor_bytes as u64)?; + tracing::trace!( + phase = "extraction", + files = file_segments.len(), + rows = chunk_count, + file_descriptor_bytes, + charged_bytes = memory.charged_bytes, + "cold build phase completed" + ); + drop(extraction_span); #[cfg(any(test, feature = "test-helpers"))] hold_after_cold_extraction_for_test(control)?; + let resolution_span = tracing::trace_span!("code_index.cold.resolution").entered(); + let resolution_base = memory.charged_bytes; + let reader = preparation.segment_reader(); + let mut reuse_memory = ConstructionMemoryV1::new(memory.operation.clone())?; + let reusable = partitioned_codec::ParentSegmentReuseRosterV1::new( + parent.map(SealedParentGenerationV1::generation), + &mut |bytes| reuse_memory.resize(bytes), + )?; + let resolved = sparse_successor::resolve_staged_generation( + &manifest, + snapshot, + &file_segments, + reader, + control, + &mut |facts| memory.resize(resolution_base.saturating_add(facts)), + &mut |segment| { + if reusable.contains(segment) { + Ok(()) + } else { + preparation.write_segment(segment).map_err(Into::into) + } + }, + )?; + drop(reusable); + drop(reuse_memory); + tracing::trace!( + phase = "resolution", + charged_bytes = memory.charged_bytes, + "cold build phase completed" + ); + drop(resolution_span); + // Resolution facts have dropped; its returned descriptors still live. + let descriptor_bytes = file_descriptor_bytes.saturating_add( + resident_bytes::staged_resolution_descriptors_bytes(&resolved), + ); + memory.resize(descriptor_bytes as u64)?; lexical_page_source::checkpoint(control)?; - staged_generation(manifest.generation_id.clone(), files) + let projection_span = tracing::trace_span!("code_index.cold.projection").entered(); + // Changed-chunk tuples are projection inputs, not resolution facts. + // Decode one sealed file after resolution drops instead of retaining + // the repository-wide tuple roster beside all binding facts. + let mut rows = Vec::<(u32, ChangedCodeChunkV1)>::new(); + let mut rows_heap = 0_usize; + let source = sealed_parent::StagedFileSourceV1::new( + &manifest, + snapshot, + &file_segments, + preparation.segment_reader(), + )?; + for descriptor in &file_segments { + lexical_page_source::checkpoint(control)?; + let artifact = source.decode_file(descriptor.file_key)?; + let key = descriptor.file_key; + let projection_file_base = descriptor_bytes + .saturating_add(resolution_view::resolution_source_bytes(&artifact)?); + let incoming = &artifact.artifacts.chunks.chunks; + let next_heap = incoming.iter().fold(rows_heap, |bytes, chunk| { + bytes + .saturating_add(chunk.id.as_str().len()) + .saturating_add(chunk.content_digest.as_str().len()) + }); + let needed = rows.len().saturating_add(incoming.len()); + if needed > rows.capacity() { + let capacity = rows.capacity().saturating_mul(2).max(needed); + memory.resize( + projection_file_base + .saturating_add(next_heap) + .saturating_add( + rows.capacity() + .saturating_add(capacity) + .saturating_mul(std::mem::size_of::<(u32, ChangedCodeChunkV1)>()), + ) as u64, + )?; + rows.try_reserve_exact(capacity - rows.len()) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; + } + memory.resize( + projection_file_base + .saturating_add(next_heap) + .saturating_add( + rows.capacity() + .saturating_mul(std::mem::size_of::<(u32, ChangedCodeChunkV1)>()), + ) as u64, + )?; + rows_heap = next_heap; + for chunk in incoming { + rows.push(( + key, + ChangedCodeChunkV1 { + chunk_id: chunk.id.clone(), + prior_digest: None, + current_digest: Some(chunk.content_digest.clone()), + }, + )); + } + drop(artifact); + memory.resize( + descriptor_bytes.saturating_add(rows_heap).saturating_add( + rows.capacity() + .saturating_mul(std::mem::size_of::<(u32, ChangedCodeChunkV1)>()), + ) as u64, + )?; + } + drop(source); + let row_tuple_bytes = rows_heap.saturating_add( + rows.capacity() + .saturating_mul(std::mem::size_of::<(u32, ChangedCodeChunkV1)>()), + ); + tracing::trace!( + phase = "projection", + rows = rows.len(), + descriptor_bytes, + row_tuple_bytes, + charged_bytes = memory.charged_bytes, + "cold build phase started" + ); + rows.sort_unstable_by(|left, right| left.1.chunk_id.cmp(&right.1.chunk_id)); + let count = rows.len(); + memory.resize( + descriptor_bytes + .saturating_add(row_tuple_bytes) + .saturating_add(count.saturating_mul( + std::mem::size_of::() + std::mem::size_of::(), + )) as u64, + )?; + let mut keys = Vec::with_capacity(count); + let mut added_or_changed = Vec::with_capacity(count); + for (key, row) in rows { + keys.push(key); + added_or_changed.push(row); + } + // The compact projection will own these headers in addition to its + // row vector; source commitments own two more tagged digests. + let projection_headers = manifest + .generation_id + .as_str() + .len() + // Changed-set manifest + reuse, request + publication, and the + // two source-commitment digests each own one tagged SHA-256 string. + .saturating_add(6 * 71) + .saturating_add(target_projection_key.schema_revision.capacity()) + .saturating_add(target_projection_key.profile_digest.as_str().len()) + .saturating_add(resident_bytes::ARC_HEADER_BYTES) + .saturating_add(std::mem::size_of::()) + .saturating_add(std::mem::size_of::()) + .saturating_add(std::mem::size_of::< + Vec<(usize, tracedecay_domain::ContentDigest)>, + >()); + let projection_base = descriptor_bytes + .saturating_add(rows_heap) + .saturating_add(projection_headers) + .saturating_add(count.saturating_mul( + std::mem::size_of::() + std::mem::size_of::(), + )); + memory.resize(projection_base as u64)?; + let (reused_count, reused_digest) = memory.canonical( + projection_base, + partitioned_codec::canonical_object_workspace_bytes(0, 2), + || { + ChangedCodeChunkSetV1::seal_reused_partition(&[]) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string())) + }, + )?; + let mut changes = ChangedCodeChunkSetV1 { + from_generation: None, + to_generation: manifest.generation_id.clone(), + manifest_digest: placeholder_digest(), + added_or_changed, + deleted: Vec::new(), + reused_count, + reused_digest, + }; + let change_workspace = changed_set_hash_workspace(&changes)?; + tracing::trace!( + phase = "projection", + retained_base_bytes = projection_base, + canonical_change_workspace_bytes = change_workspace, + "cold projection change workspace" + ); + memory.canonical(projection_base, change_workspace, || { + changes + .seal() + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string())) + })?; + manifest.source_commitments = + Some(memory.canonical(projection_base, change_workspace, || { + CodeGenerationSourceCommitmentsV1::from_changed_chunks(None, &changes) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string())) + })?); + let seal_workspace = manifest_hash_workspace(&manifest)?; + manifest.seal.expected_digest = + memory.canonical(projection_base, seal_workspace, || { + expected_seal_digest(&manifest) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string())) + })?; + let capability = emit_cold_capability( + &mut memory, + projection_base, + intake, + &manifest, + snapshot, + resolved.coverage, + )?; + let capability_bytes = resident_bytes::capability_heap_bytes(&capability); + let projection_base = projection_base.saturating_add(capability_bytes); + let request_workspace = + change_workspace.saturating_add(partitioned_codec::canonical_object_workspace_bytes( + partitioned_codec::encoded_json_rows_length(&target_projection_key)?, + 3, + )); + let request = memory.canonical(projection_base, request_workspace, || { + projection_request(None, target_projection_key, changes) + })?; + // Publication hashes stream their receipt sequence. Only one owned + // receipt and its object buffer coexist with the changed-set verifier. + let transient_receipt = resident_bytes::projection_receipt_heap_bound(&request); + memory.resize(projection_base.saturating_add(transient_receipt) as u64)?; + let receipt_json = crate::receipts::ordered_changes(&request.changes).try_fold( + 0_usize, + |largest, change| { + partitioned_codec::encoded_json_rows_length(&crate::receipts::applied_receipt( + &request, change, + )) + .map(|bytes| largest.max(bytes)) + }, + )?; + let publication_workspace = request_workspace + .saturating_add(transient_receipt) + .saturating_add(partitioned_codec::canonical_object_workspace_bytes( + receipt_json, + 12, + )); + let projection = memory.canonical(projection_base, publication_workspace, || { + project_for_publication(request).map_err(CodeIndexProductionErrorV1::Projection) + })?; + tracing::trace!( + phase = "projection", + charged_bytes = memory.charged_bytes, + canonical_publication_workspace_bytes = publication_workspace, + projection_bytes = resident_bytes::projection_heap_bytes(&projection), + "cold build phase completed" + ); + drop(projection_span); + let evidence_span = tracing::trace_span!("code_index.cold.generation_evidence").entered(); + let keys_bytes = keys.capacity().saturating_mul(std::mem::size_of::()); + let evidence_base = descriptor_bytes + .saturating_add(keys_bytes) + .saturating_add(capability_bytes) + .saturating_add(2 * 71) + .saturating_add(resident_bytes::projection_heap_bytes(&projection)); + let rosters = projection_rows::FileChunkRostersV1::from_changes( + &projection.request().changes.added_or_changed, + &keys, + evidence_base, + &mut |bytes| memory.resize(bytes), + )?; + let roster_base = evidence_base.saturating_add(rosters.retained_bytes()); + let evidence = partitioned_codec::PartitionedGenerationEvidenceRefV1::new_charged( + &projection, + &rosters, + None, + roster_base, + &mut |bytes| memory.resize(bytes), + )?; + let generation_evidence = partitioned_codec::write_generation_evidence_charged( + &evidence, + roster_base.saturating_add(evidence.retained_bytes()), + &mut |bytes| memory.resize(bytes), + &mut |segment| preparation.write_segment(segment).map_err(Into::into), + )?; + drop(evidence); + drop(rosters); + drop(keys); + tracing::trace!( + phase = "generation_evidence", + charged_bytes = memory.charged_bytes, + descriptor_bytes = + resident_bytes::generation_evidence_descriptor_bytes(&generation_evidence), + "cold build phase completed" + ); + drop(evidence_span); + let manifest_span = tracing::trace_span!("code_index.cold.manifest").entered(); + let manifest_base = descriptor_bytes + .saturating_add(capability_bytes) + .saturating_add(2 * 71) + .saturating_add(resident_bytes::projection_heap_bytes(&projection)) + .saturating_add(resident_bytes::generation_evidence_descriptor_bytes( + &generation_evidence, + )); + memory.resize(manifest_base as u64)?; + let chunk_count = u64::try_from(projection.receipt_count()).map_err(|_| { + CodeIndexProductionErrorV1::Contract("generation chunk count exceeds u64".to_owned()) + })?; + let generation_ref = partitioned_codec::PartitionedPublishedGenerationRefV1 { + format_revision: SEALED_GENERATION_FORMAT_REVISION_V1, + manifest: &manifest, + snapshot, + statistics: &resolved.statistics, + chunk_count, + chunk_policy: &chunk_policy, + repository_parse_identity: &repository_parse_identity, + ignored_source_admissions: ignored_source_roster.admissions(), + ignored_source_admissions_digest: ignored_source_roster.digest(), + file_segments: &file_segments, + file_evidence: &resolved.file_evidence, + coverage: resolved.coverage, + capability: &capability, + generation_evidence: &generation_evidence, + code_graph_pages: &resolved.code_graph_pages, + resolution_index: &resolved.resolution_index, + }; + let manifest_bytes = partitioned_codec::seal_partitioned_manifest_charged( + &generation_ref, + manifest_base, + &mut |bytes| memory.resize(bytes), + )?; + let mut string_scratch = partitioned_codec::encoded_json_rows_length(&manifest)? + .max(partitioned_codec::encoded_json_rows_length(&capability)?); + for file in &snapshot.files { + string_scratch = string_scratch.max(partitioned_codec::encoded_json_rows_length(file)?); + } + for omitted in &snapshot.omitted_sources { + string_scratch = + string_scratch.max(partitioned_codec::encoded_json_rows_length(omitted)?); + } + for admission in ignored_source_roster.admissions() { + string_scratch = + string_scratch.max(partitioned_codec::encoded_json_rows_length(admission)?); + } + let metadata_workspace = metadata_hash_workspace(&manifest, snapshot)?; + let decode_workspace = resident_bytes::partitioned_decode_workspace_bytes( + &generation_ref, + partitioned_codec::encoded_json_rows_length(&generation_ref)?, + string_scratch, + ); + // Vec -> Arc copies the bytes; both allocations coexist. Compact + // verification then owns a decoded manifest and its validation scratch. + let prepared_base = manifest_base + .saturating_add(manifest_bytes.capacity()) + .saturating_add(manifest_bytes.len()) + .saturating_add(resident_bytes::ARC_HEADER_BYTES); + let mut publication = memory.canonical( + prepared_base.saturating_add(decode_workspace), + metadata_workspace, + || { + CodeIndexSealedPublicationV1::from_prepared( + Arc::from(manifest_bytes), + projection, + Some(cold_reason), + (clones_reused, clones_computed, 0), + ) + }, + )?; + drop(generation_evidence); + drop(file_segments); + drop(resolved); + drop(capability); + drop(manifest); + let policy_bytes = resident_bytes::chunk_policy_heap_bytes(&chunk_policy); + drop(chunk_policy); + memory + .resize(resident_bytes::sealed_publication_bytes(&publication, policy_bytes) as u64)?; + tracing::trace!( + phase = "manifest", + charged_bytes = memory.charged_bytes, + chunk_count, + manifest_bytes = publication.manifest_bytes().len(), + "cold build phase completed" + ); + drop(manifest_span); + publication.construction_memory = Some(Arc::new(memory)); + Ok(publication) } } diff --git a/crates/tracedecay-code-index/src/production/module_resolution.rs b/crates/tracedecay-code-index/src/production/module_resolution.rs index 6bf8aec878..6a335b604d 100644 --- a/crates/tracedecay-code-index/src/production/module_resolution.rs +++ b/crates/tracedecay-code-index/src/production/module_resolution.rs @@ -1,9 +1,9 @@ //! Python, Go, Java, and Ruby call binding over the sealed file set. //! //! Each language binds a call through its own import form and one module -//! rule, reading only the per-file artifacts the seal already holds: the -//! indexed source paths, each file's parser-attested import rows, and the -//! package, module, and class symbols its extractor published. +//! rule, using source-path metadata and fallibly loaded compact binding +//! facts: each file's parser-attested import rows and the package, module, +//! and class symbols its extractor published. //! //! - Python: `from m import f`, `import m as a` / `import a.b` with dotted //! member paths, relative imports, globs, and names a package's @@ -21,14 +21,18 @@ //! gap, disclosed so `callers` and `file_dependents` report partial //! coverage; a binding of an external module binds nothing and is no gap. -use std::collections::{HashMap, HashSet, VecDeque}; -use std::sync::OnceLock; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, OnceLock}; use tracedecay_code_extraction::{CallableArityV1, ImportModuleKindV1, ImportNamespaceV1}; use tracedecay_domain::{NodeKind, RelationEdgeKindV1, SymbolOccurrenceId}; -use super::FileGenerationArtifactsV1; -use super::resolution_view::ResolutionFileV1; +use super::CodeIndexProductionErrorV1; +use super::resolution_view::ResolutionMemoryChangeV1; +use super::resolution_view::{ + ResolutionFileFactsV1, ResolutionFileV1, reserve_resolution_map, reserve_resolution_set, + reserve_resolution_vec, +}; use super::typescript_resolution::{ImportBindingOutcomeV1, join_normalized, split_parent}; use crate::chunks::relation_target_kind_is_compatible; use crate::chunks::{CodeIndexImportEvidenceV1, CodeIndexUnresolvedReferenceV1}; @@ -50,15 +54,15 @@ pub(super) fn caller_scope<'r>( (language == "ruby").then_some(&reference.from_occurrence) } -type SymbolRef<'a> = (usize, &'a LineageSymbolRecordV1); +type SymbolRef = (usize, Arc); /// A module path under a source root to its `(root, target)` candidates. type PythonModulesV1 = HashMap>; /// What one step of module resolution reached. #[derive(Clone, Debug)] -enum TargetV1<'a> { - Symbol(SymbolRef<'a>), +enum TargetV1 { + Symbol(SymbolRef), /// A Python module file (`m.py` or a package `__init__.py`). ModuleFile(usize), /// A Python namespace package directory without `__init__.py`. @@ -74,12 +78,12 @@ enum TargetV1<'a> { pub(super) struct ModuleImportIndexV1<'a, T> { /// Import-module source paths to their file index. sources: HashMap<&'a str, usize>, - /// Every sealed file, by index; only import-module sources are read. + /// Every source, by index; only import-module binding facts are read. files: &'a [T], /// `(language, directory)` to the source files directly inside it. dirs: HashMap<(&'a str, &'a str), Vec>, /// Every symbol by exact qualified name. - by_qualified: HashMap<&'a str, Vec>>, + by_qualified: HashMap<&'a str, Vec<(usize, &'a Arc)>>, /// Directories holding an `__init__.py`: regular Python packages. python_packages: HashSet<&'a str>, python_modules: PythonModulesV1, @@ -114,7 +118,23 @@ enum PythonModuleV1 { } impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { - pub(super) fn new(files: &'a [T]) -> Self { + pub(super) fn release_file_memo(&mut self, index: usize) { + self.ruby_loaded[index].take(); + } + + pub(super) fn new(files: &'a [T]) -> Result { + Self::new_charged(files, &mut |_| Ok(())) + } + + pub(super) fn new_charged( + files: &'a [T], + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result { + charge(ResolutionMemoryChangeV1::Retain( + files + .len() + .saturating_mul(std::mem::size_of::, bool)>>()), + ))?; let mut index = Self { sources: HashMap::new(), files, @@ -136,58 +156,96 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { }; for (file_index, file) in files.iter().enumerate() { if is_module_import_language(file.language()) { - index.register(file_index, file.as_ref()); + index.register(file_index, file.binding()?, charge)?; } } index .go_modules .sort_by(|left, right| right.0.len().cmp(&left.0.len()).then(left.cmp(right))); - (index.python_modules, index.python_namespace_dirs) = index.python_module_candidates(); - index + (index.python_modules, index.python_namespace_dirs) = + index.python_module_candidates(charge)?; + Ok(index) } /// Index one Python, Go, Java, or Ruby file: its path, directory, and /// symbols, and the package, module, and load-path facts its language /// resolves through. - fn register(&mut self, file_index: usize, file: &'a FileGenerationArtifactsV1) { - let language = file.extraction.language.as_str(); - let path = file.authority.logical_path.as_str(); + fn register( + &mut self, + file_index: usize, + file: &'a dyn ResolutionFileFactsV1, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result<(), CodeIndexProductionErrorV1> { + let language = file.language(); + let path = file.logical_path(); let (dir, name) = split_parent(path); if language == "go" && name == "go.mod" { if let Some(module) = file - .artifacts - .symbols + .symbols() .iter() .find(|symbol| symbol.kind == NodeKind::Module.as_str()) { + reserve_resolution_vec(&mut self.go_modules, 1, charge)?; self.go_modules.push((module.simple_name.as_str(), dir)); } - return; + return Ok(()); + } + if !self.sources.contains_key(path) { + reserve_resolution_map(&mut self.sources, charge)?; } self.sources.insert(path, file_index); + if !self.dirs.contains_key(&(language, dir)) { + reserve_resolution_map(&mut self.dirs, charge)?; + } + reserve_resolution_vec(self.dirs.entry((language, dir)).or_default(), 1, charge)?; self.dirs .entry((language, dir)) .or_default() .push(file_index); - for symbol in &file.artifacts.symbols { + for symbol in file.symbols() { + if !self + .by_qualified + .contains_key(symbol.qualified_name.as_str()) + { + reserve_resolution_map(&mut self.by_qualified, charge)?; + } + reserve_resolution_vec( + self.by_qualified + .entry(symbol.qualified_name.as_str()) + .or_default(), + 1, + charge, + )?; self.by_qualified .entry(symbol.qualified_name.as_str()) .or_default() .push((file_index, symbol)); - self.register_declaration(language, file_index, symbol); + self.register_declaration(language, file_index, symbol, charge)?; } - for row in &file.artifacts.callable_arities { + for row in file.callable_arities() { + if !self.arities.contains_key(&row.occurrence) { + reserve_resolution_map(&mut self.arities, charge)?; + } self.arities.insert(&row.occurrence, row.arity); } match language { "python" if name == "__init__.py" => { + if !self.python_packages.contains(dir) { + reserve_resolution_set(&mut self.python_packages, charge)?; + } self.python_packages.insert(dir); } "go" => { let mut suffix = dir; + if !self.go_dir_suffixes.contains(suffix) { + reserve_resolution_set(&mut self.go_dir_suffixes, charge)?; + } self.go_dir_suffixes.insert(suffix); while let Some((_, rest)) = suffix.split_once('/') { suffix = rest; + if !self.go_dir_suffixes.contains(suffix) { + reserve_resolution_set(&mut self.go_dir_suffixes, charge)?; + } self.go_dir_suffixes.insert(suffix); } } @@ -196,6 +254,14 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { .strip_prefix("lib/") .or_else(|| path.rfind("/lib/").map(|at| &path[at + "/lib/".len()..])); if let Some(under_lib) = under_lib { + if !self.ruby_load_path.contains_key(under_lib) { + reserve_resolution_map(&mut self.ruby_load_path, charge)?; + } + reserve_resolution_vec( + self.ruby_load_path.entry(under_lib).or_default(), + 1, + charge, + )?; self.ruby_load_path .entry(under_lib) .or_default() @@ -203,10 +269,15 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { } } "java" if !self.java_file_packages.contains_key(&file_index) => { + if !self.java_packages.contains_key("") { + reserve_resolution_map(&mut self.java_packages, charge)?; + } + reserve_resolution_vec(self.java_packages.entry("").or_default(), 1, charge)?; self.java_packages.entry("").or_default().push(file_index); } _ => {} } + Ok(()) } /// Record a Go package clause, a Java package, or a Ruby constant. @@ -215,26 +286,44 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { language: &str, file_index: usize, symbol: &'a LineageSymbolRecordV1, - ) { + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result<(), CodeIndexProductionErrorV1> { let name = symbol.simple_name.as_str(); match language { "go" if symbol.kind == NodeKind::GoPackage.as_str() => { + if !self.go_packages.contains_key(&file_index) { + reserve_resolution_map(&mut self.go_packages, charge)?; + } self.go_packages.insert(file_index, name); } "java" if symbol.kind == NodeKind::Package.as_str() => { + if !self.java_packages.contains_key(name) { + reserve_resolution_map(&mut self.java_packages, charge)?; + } + reserve_resolution_vec(self.java_packages.entry(name).or_default(), 1, charge)?; self.java_packages.entry(name).or_default().push(file_index); + if !self.java_file_packages.contains_key(&file_index) { + reserve_resolution_map(&mut self.java_file_packages, charge)?; + } self.java_file_packages.insert(file_index, name); } "ruby" => { + if !self.ruby_symbols.contains_key(&symbol.occurrence) { + reserve_resolution_map(&mut self.ruby_symbols, charge)?; + } self.ruby_symbols.insert(&symbol.occurrence, symbol); if symbol.kind == NodeKind::Module.as_str() || symbol.kind == NodeKind::Class.as_str() { + if !self.ruby_constants.contains(name) { + reserve_resolution_set(&mut self.ruby_constants, charge)?; + } self.ruby_constants.insert(name); } } _ => {} } + Ok(()) } /// Every module path under a source root a Python source can be @@ -242,7 +331,10 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { /// and each directory of sources that is not a regular package as a /// namespace package. A root is any ancestor that is not itself a /// regular package. - fn python_module_candidates(&self) -> (PythonModulesV1, HashSet) { + fn python_module_candidates( + &self, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result<(PythonModulesV1, HashSet), CodeIndexProductionErrorV1> { let mut modules = PythonModulesV1::new(); let mut namespace_dirs = HashSet::new(); for (path, file_index) in &self.sources { @@ -254,20 +346,33 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { None if stem == "__init__" => continue, None => stem, }; - insert_python_roots(&mut modules, &self.python_packages, module, || { - PythonModuleV1::File(*file_index) - }); + insert_python_roots( + &mut modules, + &self.python_packages, + module, + 0, + || PythonModuleV1::File(*file_index), + charge, + )?; let mut dir = split_parent(path).0; while !dir.is_empty() { - if !self.python_packages.contains(dir) && namespace_dirs.insert(dir.to_owned()) { - insert_python_roots(&mut modules, &self.python_packages, dir, || { - PythonModuleV1::Dir(dir.to_owned()) - }); + if !self.python_packages.contains(dir) && !namespace_dirs.contains(dir) { + reserve_resolution_set(&mut namespace_dirs, charge)?; + charge(ResolutionMemoryChangeV1::Retain(dir.len()))?; + namespace_dirs.insert(dir.to_owned()); + insert_python_roots( + &mut modules, + &self.python_packages, + dir, + dir.len(), + || PythonModuleV1::Dir(dir.to_owned()), + charge, + )?; } dir = split_parent(dir).0; } } - (modules, namespace_dirs) + Ok((modules, namespace_dirs)) } /// How the retained call `reference` in file `index` binds, or `None` @@ -277,38 +382,46 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { &self, index: usize, reference: &CodeIndexUnresolvedReferenceV1, - ) -> Option> { + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result, CodeIndexProductionErrorV1> { if reference.kind != RelationEdgeKindV1::Calls { - return None; + return Ok(None); } - let file = self.files[index].as_ref(); + let source = &self.files[index]; let name = reference.reference_name.as_str(); - let language = file.extraction.language.as_str(); + let language = source.language(); let target = match language { - "python" => self.python_call(file, &identifier_path(name, &["."])?), - "go" => self.go_call(index, file, &identifier_path(name, &["."])?), - "java" => self.java_call( - index, - file, - &identifier_path(name, &["."])?, - reference.argument_count, - ), + "python" | "go" | "java" => { + let Some(segments) = identifier_path(name, &["."]) else { + return Ok(None); + }; + let file = source.binding()?; + match language { + "python" => self.python_call(file, &segments)?, + "go" => self.go_call(index, file, &segments), + "java" => self.java_call(index, file, &segments, reference.argument_count), + _ => None, + } + } "ruby" => { let (absolute, name) = match name.strip_prefix("::") { Some(rest) => (true, rest), None => (false, name), }; - let segments = identifier_path(name, &[".", "::"])?; - self.ruby_call( - index, - caller_scope(language, reference)?, - absolute, - &segments, - ) + let Some(segments) = identifier_path(name, &[".", "::"]) else { + return Ok(None); + }; + let Some(scope) = caller_scope(language, reference) else { + return Ok(None); + }; + self.ruby_call(index, scope, absolute, &segments, charge)? } _ => None, - }?; - Some(match target { + }; + let Some(target) = target else { + return Ok(None); + }; + Ok(Some(match target { TargetV1::Symbol((target_index, symbol)) if relation_target_kind_is_compatible(RelationEdgeKindV1::Calls, &symbol.kind) => { @@ -322,21 +435,28 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { | TargetV1::Opaque => ImportBindingOutcomeV1::ValueMember, TargetV1::External => ImportBindingOutcomeV1::External, TargetV1::Unresolved => ImportBindingOutcomeV1::Unresolved, - }) + })) } /// Every retained Python, Go, Java, and Ruby call that is a caller gap. - pub(super) fn call_gaps<'r>( + pub(super) fn call_gaps_charged<'r>( &self, references: impl Iterator, - ) -> Vec { - references - .filter(|&(index, reference)| { - is_module_import_language(self.files[index].language()) - && self.is_call_gap(index, reference) - }) - .map(|(_, reference)| reference.clone()) - .collect() + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result, CodeIndexProductionErrorV1> { + let mut gaps = Vec::new(); + for (index, reference) in references { + if is_module_import_language(self.files[index].language()) + && self.is_call_gap(index, reference, charge)? + { + super::resolution_view::reserve_resolution_vec(&mut gaps, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + super::resident_bytes::unresolved_heap_bytes(reference), + ))?; + gaps.push(reference.clone()); + } + } + Ok(gaps) } /// Whether the retained call `reference` in file `index` is a caller gap: @@ -344,11 +464,16 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { /// call on a runtime value (dynamic dispatch). A bare call no import /// names is a builtin or a local in Python and Go, and a call on `self` /// that may be inherited in Java and Ruby. - fn is_call_gap(&self, index: usize, reference: &CodeIndexUnresolvedReferenceV1) -> bool { + fn is_call_gap( + &self, + index: usize, + reference: &CodeIndexUnresolvedReferenceV1, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result { if reference.kind != RelationEdgeKindV1::Calls { - return false; + return Ok(false); } - match self.call_outcome(index, reference) { + Ok(match self.call_outcome(index, reference, charge)? { Some(ImportBindingOutcomeV1::Unresolved) => true, Some( ImportBindingOutcomeV1::Bound(..) @@ -360,13 +485,13 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { || (matches!(self.files[index].language(), "java" | "ruby") && identifier_path(&reference.reference_name, &["::"]).is_some()) } - } + }) } /// The unique symbol `container::name` in `file_index`. A package /// clause is no member: `package main` shares `main.go::main` with /// `func main`. - fn member(&self, file_index: usize, container: &str, name: &str) -> Option> { + fn member(&self, file_index: usize, container: &str, name: &str) -> Option { self.member_accepting(file_index, container, name, None) } @@ -379,7 +504,7 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { container: &str, name: &str, arguments: Option, - ) -> Option> { + ) -> Option { let found = self .by_qualified .get(format!("{container}::{name}").as_str()) @@ -404,7 +529,7 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { }) .collect::>(); Some(match accepting.as_slice() { - [symbol] => TargetV1::Symbol(*symbol), + [symbol] => TargetV1::Symbol((symbol.0, Arc::clone(symbol.1))), _ => TargetV1::Unresolved, }) } @@ -413,10 +538,10 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { fn python_call( &self, - file: &FileGenerationArtifactsV1, + file: &dyn ResolutionFileFactsV1, segments: &[&str], - ) -> Option> { - let path = file.authority.logical_path.as_str(); + ) -> Result, CodeIndexProductionErrorV1> { + let path = file.logical_path(); // The longest dotted prefix the file binds: `import a.b` binds the // path `a.b`, every other form one name. for split in (1..=segments.len()).rev() { @@ -425,15 +550,15 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { let row = match rows.as_slice() { [] => continue, [row] => *row, - _ => return Some(TargetV1::Unresolved), + _ => return Ok(Some(TargetV1::Unresolved)), }; - let start = self.python_row_target(path, row, 0); - return Some(self.python_walk(start, &segments[split..], 0)); + let start = self.python_row_target(path, row, 0)?; + return self.python_walk(start, &segments[split..], 0).map(Some); } if let [name] = segments { return self.python_glob_member(file, name, 0); } - None + Ok(None) } fn python_row_target( @@ -441,19 +566,24 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { path: &str, row: &CodeIndexImportEvidenceV1, depth: usize, - ) -> TargetV1<'a> { + ) -> Result { let module = self.python_module(path, &row.module_specifier); match row.imported_name.as_deref() { - Some("*") | None => module, + Some("*") | None => Ok(module), Some(imported) => self.python_member(module, imported, depth + 1), } } - fn python_walk(&self, mut target: TargetV1<'a>, rest: &[&str], depth: usize) -> TargetV1<'a> { + fn python_walk( + &self, + mut target: TargetV1, + rest: &[&str], + depth: usize, + ) -> Result { for segment in rest { target = match target { TargetV1::ModuleFile(_) | TargetV1::ModuleDir(_) => { - self.python_member(target, segment, depth) + self.python_member(target, segment, depth)? } TargetV1::Symbol((file_index, symbol)) if symbol.kind == NodeKind::Class.as_str() => @@ -461,18 +591,23 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { self.member(file_index, &symbol.qualified_name, segment) .unwrap_or(TargetV1::Unresolved) } - TargetV1::Symbol(_) | TargetV1::Opaque => return TargetV1::Opaque, - TargetV1::External | TargetV1::Unresolved => return target, + TargetV1::Symbol(_) | TargetV1::Opaque => return Ok(TargetV1::Opaque), + TargetV1::External | TargetV1::Unresolved => return Ok(target), }; } - target + Ok(target) } /// `name` read from a module: its own definition, a name it imports, a /// submodule of a package, or a name one of its globs provides. - fn python_member(&self, module: TargetV1<'a>, name: &str, depth: usize) -> TargetV1<'a> { + fn python_member( + &self, + module: TargetV1, + name: &str, + depth: usize, + ) -> Result { if depth > MAX_FORWARDING_DEPTH { - return TargetV1::Unresolved; + return Ok(TargetV1::Unresolved); } let (module_file, dir) = match &module { TargetV1::ModuleFile(file_index) => { @@ -485,67 +620,67 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { } TargetV1::ModuleDir(dir) => (None, Some(dir.as_str())), TargetV1::Symbol(_) | TargetV1::Opaque | TargetV1::External | TargetV1::Unresolved => { - return module.clone(); + return Ok(module.clone()); } }; if let Some(file_index) = module_file { - let file = self.files[file_index].as_ref(); - let path = file.authority.logical_path.as_str(); + let source = &self.files[file_index]; + let path = source.logical_path(); if let Some(found) = self.member(file_index, path, name) { - return found; + return Ok(found); } - match named_rows(file, name).as_slice() { + match named_rows(source.binding()?, name).as_slice() { [] => {} [row] => return self.python_row_target(path, row, depth), - _ => return TargetV1::Unresolved, + _ => return Ok(TargetV1::Unresolved), } } if let Some(dir) = dir { let submodule = join_normalized(dir, name); if let Some(found) = self.python_exact_module(&submodule) { - return found; + return Ok(found); } } if let Some(file_index) = module_file && let Some(found) = - self.python_glob_member(self.files[file_index].as_ref(), name, depth + 1) + self.python_glob_member(self.files[file_index].binding()?, name, depth + 1)? { - return found; + return Ok(found); } - TargetV1::Unresolved + Ok(TargetV1::Unresolved) } /// `name` through the file's `from m import *` rows: the unique module /// that provides it, a gap when a project glob cannot be followed. fn python_glob_member( &self, - file: &FileGenerationArtifactsV1, + file: &dyn ResolutionFileFactsV1, name: &str, depth: usize, - ) -> Option> { - let path = file.authority.logical_path.as_str(); + ) -> Result, CodeIndexProductionErrorV1> { + let path = file.logical_path(); let mut found = Vec::new(); let mut unresolved = false; - for row in file.artifacts.imports.iter().filter(|row| row.is_glob) { + for row in file.imports().iter().filter(|row| row.is_glob) { match self.python_module(path, &row.module_specifier) { TargetV1::External => {} TargetV1::Unresolved => unresolved = true, - module => match self.python_member(module, name, depth) { + module => match self.python_member(module, name, depth)? { TargetV1::Unresolved => {} target => found.push(target), }, } } - match found.as_slice() { + Ok(match found.as_slice() { [target] => Some(target.clone()), [] if unresolved => Some(TargetV1::Unresolved), [] => None, _ => Some(TargetV1::Unresolved), - } + }) } /// The module a specifier names from the file at `from_path`. - fn python_module(&self, from_path: &str, specifier: &str) -> TargetV1<'a> { + fn python_module(&self, from_path: &str, specifier: &str) -> TargetV1 { let dots = specifier.bytes().take_while(|byte| *byte == b'.').count(); let dotted = &specifier[dots..]; if dots > 0 { @@ -597,7 +732,7 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { /// The module at exactly this project path: `path.py`, /// `path/__init__.py`, or a directory of sources. - fn python_exact_module(&self, module: &str) -> Option> { + fn python_exact_module(&self, module: &str) -> Option { if let Some(file_index) = self.sources.get(format!("{module}.py").as_str()) { return Some(TargetV1::ModuleFile(*file_index)); } @@ -619,22 +754,22 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { fn go_call( &self, index: usize, - file: &FileGenerationArtifactsV1, + file: &dyn ResolutionFileFactsV1, segments: &[&str], - ) -> Option> { + ) -> Option { match segments { [name] => { - let dir = split_parent(file.authority.logical_path.as_str()).0; + let dir = split_parent(file.logical_path()).0; let package = self.go_packages.get(&index).copied(); let mut found = self.go_package_functions(dir, package, name); - for row in file.artifacts.imports.iter().filter(|row| row.is_glob) { + for row in file.imports().iter().filter(|row| row.is_glob) { if let TargetV1::ModuleDir(dir) = self.go_package_dir(&row.module_specifier) { found.extend(self.go_package_functions(&dir, None, name)); } } match found.as_slice() { [] => None, - [symbol] => Some(TargetV1::Symbol(*symbol)), + [symbol] => Some(TargetV1::Symbol(symbol.clone())), _ => Some(TargetV1::Unresolved), } } @@ -647,7 +782,7 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { Some(match self.go_package_dir(&row.module_specifier) { TargetV1::ModuleDir(dir) => { match self.go_package_functions(&dir, None, name).as_slice() { - [symbol] => TargetV1::Symbol(*symbol), + [symbol] => TargetV1::Symbol(symbol.clone()), _ => TargetV1::Unresolved, } } @@ -660,12 +795,7 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { /// Package-level functions named `name` in the Go sources of `dir`, /// restricted to one package clause when `package` is given. - fn go_package_functions( - &self, - dir: &str, - package: Option<&str>, - name: &str, - ) -> Vec> { + fn go_package_functions(&self, dir: &str, package: Option<&str>, name: &str) -> Vec { self.dirs .get(&("go", dir)) .into_iter() @@ -696,7 +826,7 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { /// The project directory an import path names through the longest /// `go.mod` module path it starts with. Without a `go.mod`, a path whose /// tail names a source directory is project code the seal cannot place. - fn go_package_dir(&self, import_path: &str) -> TargetV1<'a> { + fn go_package_dir(&self, import_path: &str) -> TargetV1 { let module = self.go_modules.iter().find_map(|(module, root)| { let rest = import_path.strip_prefix(module)?; match rest.strip_prefix('/') { @@ -731,10 +861,10 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { fn java_call( &self, index: usize, - file: &FileGenerationArtifactsV1, + file: &dyn ResolutionFileFactsV1, segments: &[&str], arguments: Option, - ) -> Option> { + ) -> Option { let (method, qualifier) = segments.split_last()?; if qualifier.is_empty() { return self.java_static_import(file, method, arguments); @@ -756,13 +886,12 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { /// the method, and a gap when a project glob class does not. fn java_static_import( &self, - file: &FileGenerationArtifactsV1, + file: &dyn ResolutionFileFactsV1, method: &str, arguments: Option, - ) -> Option> { + ) -> Option { let statics = file - .artifacts - .imports + .imports() .iter() .filter(|row| row.namespace == ImportNamespaceV1::Value); if let Some(row) = statics @@ -781,7 +910,7 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { } } match found.as_slice() { - [symbol] => Some(TargetV1::Symbol(*symbol)), + [symbol] => Some(TargetV1::Symbol(symbol.clone())), [] if !project_glob => None, _ => Some(TargetV1::Unresolved), } @@ -792,12 +921,11 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { fn java_class_name( &self, index: usize, - file: &FileGenerationArtifactsV1, + file: &dyn ResolutionFileFactsV1, simple: &str, ) -> Option { let type_rows = file - .artifacts - .imports + .imports() .iter() .filter(|row| row.namespace == ImportNamespaceV1::Type); if let Some(row) = type_rows @@ -824,7 +952,7 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { } /// The project file and symbol of the top-level class `qualified`. - fn java_class(&self, qualified: &str) -> Option> { + fn java_class(&self, qualified: &str) -> Option { let (package, simple) = qualified.rsplit_once('.').unwrap_or(("", qualified)); self.java_packages .get(package) @@ -842,7 +970,7 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { /// call's `arguments`; a project class without that method (inherited) /// or without one such overload is a gap, a class outside the project /// binds nothing. - fn java_class_member(&self, class: &str, method: &str, arguments: Option) -> TargetV1<'a> { + fn java_class_member(&self, class: &str, method: &str, arguments: Option) -> TargetV1 { let Some((file_index, symbol)) = self.java_class(class) else { let package = class.rsplit_once('.').map_or("", |(package, _)| package); return if self.java_packages.contains_key(package) { @@ -867,14 +995,17 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { from: &SymbolOccurrenceId, absolute: bool, segments: &[&str], - ) -> Option> { - let (method, constant) = segments.split_last()?; + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result, CodeIndexProductionErrorV1> { + let Some((method, constant)) = segments.split_last() else { + return Ok(None); + }; if constant.is_empty() || !constant .iter() .all(|segment| segment.starts_with(|c: char| c.is_ascii_uppercase())) { - return None; + return Ok(None); } let constant = constant.join("::"); let path = self.files[index].logical_path(); @@ -888,13 +1019,19 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { .and_then(|symbol| symbol.qualified_name.strip_prefix(path)?.strip_prefix("::")) .map(|scope| scope.split("::").collect::>()) else { - return Some(TargetV1::Unresolved); + return Ok(Some(TargetV1::Unresolved)); }; scope.pop(); scope }; - let (loaded, unresolved_require) = - self.ruby_loaded[index].get_or_init(|| self.ruby_require_closure(index)); + let closure = &self.ruby_loaded[index]; + let (loaded, unresolved_require) = match closure.get() { + Some(loaded) => loaded, + None => { + let loaded = self.ruby_require_closure(index, charge)?; + closure.get_or_init(|| loaded) + } + }; for depth in (0..=nesting.len()).rev() { let qualified = match nesting[..depth].join("::") { prefix if prefix.is_empty() => constant.clone(), @@ -909,37 +1046,50 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { .collect::>(); match found.as_slice() { [] => {} - [target] => return Some(target.clone()), - _ => return Some(TargetV1::Unresolved), + [target] => return Ok(Some(target.clone())), + _ => return Ok(Some(TargetV1::Unresolved)), } } - Some( + Ok(Some( if !*unresolved_require && !self.ruby_constants.contains(segments[0]) { TargetV1::External } else { TargetV1::Unresolved }, - ) + )) } /// The files `index` loads through `require_relative` and project /// `require` chains, itself first, and whether a project-relative /// require named no indexed file. - fn ruby_require_closure(&self, index: usize) -> (Vec, bool) { - let mut loaded = vec![index]; - let mut seen = HashSet::from([index]); - let mut queue = VecDeque::from([index]); + fn ruby_require_closure( + &self, + index: usize, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result<(Vec, bool), CodeIndexProductionErrorV1> { + let mut loaded = Vec::new(); + let mut seen = HashSet::new(); + reserve_resolution_vec(&mut loaded, 1, charge)?; + reserve_resolution_set(&mut seen, charge)?; + loaded.push(index); + seen.insert(index); + let mut cursor = 0; let mut unresolved = false; - while let Some(current) = queue.pop_front() { - let file = self.files[current].as_ref(); - let (dir, _) = split_parent(file.authority.logical_path.as_str()); - for row in &file.artifacts.imports { + // The retained discovery order is also the breadth-first queue. + while cursor < loaded.len() { + let current = loaded[cursor]; + cursor += 1; + let file = self.files[current].binding()?; + let (dir, _) = split_parent(file.logical_path()); + for row in file.imports() { let target = self.ruby_require_target(dir, row); match target { Some(target) => { - if seen.insert(target) { + if !seen.contains(&target) { + reserve_resolution_vec(&mut loaded, 1, charge)?; + reserve_resolution_set(&mut seen, charge)?; + seen.insert(target); loaded.push(target); - queue.push_back(target); } } None if row.module_kind == ImportModuleKindV1::ProjectRelative => { @@ -949,7 +1099,7 @@ impl<'a, T: ResolutionFileV1> ModuleImportIndexV1<'a, T> { } } } - (loaded, unresolved) + Ok((loaded, unresolved)) } /// The indexed file one require names: relative to the requiring file, @@ -980,31 +1130,45 @@ fn insert_python_roots( modules: &mut PythonModulesV1, packages: &HashSet<&str>, module: &str, + target_heap_bytes: usize, target: impl Fn() -> PythonModuleV1, -) { + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<(), CodeIndexProductionErrorV1> { let mut root_end = 0; loop { let root = &module[..root_end]; if !packages.contains(root.strip_suffix('/').unwrap_or(root)) { - modules - .entry(module[root_end..].to_owned()) - .or_default() - .push((root.to_owned(), target())); + let key = &module[root_end..]; + if !modules.contains_key(key) { + reserve_resolution_map(modules, charge)?; + charge(ResolutionMemoryChangeV1::Retain(key.len()))?; + modules.insert(key.to_owned(), Vec::new()); + } + let rows = modules.get_mut(key).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "Python module insertion lost its rows".to_owned(), + ) + })?; + reserve_resolution_vec(rows, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + root.len().saturating_add(target_heap_bytes), + ))?; + rows.push((root.to_owned(), target())); } match module[root_end..].find('/') { Some(offset) => root_end += offset + 1, None => break, } } + Ok(()) } /// The file's private import rows binding `local`. fn named_rows<'f>( - file: &'f FileGenerationArtifactsV1, + file: &'f dyn ResolutionFileFactsV1, local: &str, ) -> Vec<&'f CodeIndexImportEvidenceV1> { - file.artifacts - .imports + file.imports() .iter() .filter(|row| !row.is_public && !row.is_glob && row.local_name.as_deref() == Some(local)) .collect() diff --git a/crates/tracedecay-code-index/src/production/partitioned_codec.rs b/crates/tracedecay-code-index/src/production/partitioned_codec.rs index a4b78f237d..bbaa09af79 100644 --- a/crates/tracedecay-code-index/src/production/partitioned_codec.rs +++ b/crates/tracedecay-code-index/src/production/partitioned_codec.rs @@ -147,19 +147,19 @@ pub(super) struct PartitionedFileEvidenceDescriptorV1 { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct PartitionedEvidencePageDescriptorV1 { - page_ordinal: u32, - page_digest: ManifestDigest, - page_size_bytes: u64, + pub(super) page_ordinal: u32, + pub(super) page_digest: ManifestDigest, + pub(super) page_size_bytes: u64, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct PartitionedGenerationEvidenceDescriptorV1 { - segment_digest: ManifestDigest, - segment_size_bytes: u64, + pub(super) segment_digest: ManifestDigest, + pub(super) segment_size_bytes: u64, /// Prefix containing the typed generation evidence JSON. - evidence_size_bytes: u64, - pages: Vec, + pub(super) evidence_size_bytes: u64, + pub(super) pages: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -430,19 +430,48 @@ pub(super) struct PartitionedGenerationEvidenceRefV1<'a> { } impl<'a> PartitionedGenerationEvidenceRefV1<'a> { - /// `rosters` holds every file whose chunks `projection` adds or changes. + /// Fully decoded callers already own the complete input and its workspace. pub(super) fn new( projection: &'a ProjectionPublicationHandoffV1, rosters: &FileChunkRostersV1<'_>, lineage_prior_generation: Option<&'a CodeGenerationId>, ) -> Result { - let request = projection.request(); + Self::new_charged( + projection, + rosters, + lineage_prior_generation, + 0, + &mut |_| Ok(()), + ) + } + + pub(super) fn new_charged( + projection: &'a ProjectionPublicationHandoffV1, + rosters: &FileChunkRostersV1<'_>, + lineage_prior_generation: Option<&'a CodeGenerationId>, + base: usize, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result { + let projection_request = + PersistedProjectionRequestRefV1::new(projection.request(), rosters, base, retain)?; + retain( + (base + .saturating_add(projection_request.retained_bytes()) + .saturating_add(PersistedBatchReceiptRefV1::allocation_bytes(projection))) + as u64, + )?; Ok(Self { lineage_prior_generation, - projection_request: PersistedProjectionRequestRefV1::new(request, rosters)?, - projection_receipt: PersistedBatchReceiptRefV1::new(request, projection.receipt())?, + projection_request, + projection_receipt: PersistedBatchReceiptRefV1::new(projection), }) } + + pub(super) fn retained_bytes(&self) -> usize { + self.projection_request + .retained_bytes() + .saturating_add(self.projection_receipt.allocation_bytes_held()) + } } #[derive(Clone, Copy)] @@ -638,11 +667,16 @@ impl<'a, P> PartitionedEvidencePageWriterV1<'a, P> where P: FnMut(SealedGenerationSegmentPublicationV1<'_>) -> Result<(), CodeIndexProductionErrorV1>, { + #[cfg(test)] fn new(publish: &'a mut P) -> Self { + Self::with_descriptor_capacity(publish, 0) + } + + fn with_descriptor_capacity(publish: &'a mut P, page_count: usize) -> Self { Self { publish, page: Vec::with_capacity(GENERATION_EVIDENCE_PAGE_MAX_BYTES_V1), - descriptors: Vec::new(), + descriptors: Vec::with_capacity(page_count), segment_hasher: Sha256::new(), segment_size_bytes: 0, publish_error: None, @@ -944,6 +978,106 @@ where /// after every file. const SEALED_ENCODE_WINDOW_FILES_PER_WORKER_V1: usize = 4; +/// Parent-authenticated non-file segments that need no new durable write. +/// File segments use the artifact-aware planner before encoding; generation +/// evidence always seals the new request and is deliberately absent here. +#[derive(Default)] +pub(super) struct ParentSegmentReuseRosterV1 { + file_evidence: BTreeSet, + graph_pages: BTreeSet, + resolution_index: BTreeSet, +} + +impl ParentSegmentReuseRosterV1 { + pub(super) fn new( + parent: Option<&PartitionedPublishedGenerationV1>, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result { + let Some(parent) = parent else { + return Ok(Self::default()); + }; + let tree = + |count| resident_bytes::btree_bytes(count, std::mem::size_of::()); + let workspace = tree(parent.file_evidence.len()) + .saturating_add(tree(parent.code_graph_pages.len())) + .saturating_add(tree(parent.resolution_index.segments().count())) + .saturating_add( + parent + .file_evidence + .iter() + .map(|row| row.segment_digest.as_str().len()) + .sum::(), + ) + .saturating_add( + parent + .code_graph_pages + .iter() + .map(|row| row.page_digest.as_str().len()) + .sum::(), + ) + .saturating_add( + parent + .resolution_index + .segments() + .map(|row| row.segment_digest.as_str().len()) + .sum::(), + ); + retain(workspace as u64)?; + let mut roster = Self::default(); + // Insert directly: BTreeSet's bulk FromIterator may first allocate + // a sorting vector in addition to the final tree. + for row in &parent.file_evidence { + roster.file_evidence.insert(row.segment_digest.clone()); + } + for row in &parent.code_graph_pages { + roster.graph_pages.insert(row.page_digest.clone()); + } + for row in parent.resolution_index.segments() { + roster.resolution_index.insert(row.segment_digest.clone()); + } + retain(roster.retained_bytes() as u64)?; + Ok(roster) + } + + fn retained_bytes(&self) -> usize { + [ + &self.file_evidence, + &self.graph_pages, + &self.resolution_index, + ] + .into_iter() + .fold(0_usize, |bytes, set| { + bytes + .saturating_add(resident_bytes::btree_bytes( + set.len(), + std::mem::size_of::(), + )) + .saturating_add( + set.iter() + .map(|digest| digest.as_str().len()) + .sum::(), + ) + }) + } + + pub(super) fn contains(&self, publication: SealedGenerationSegmentPublicationV1<'_>) -> bool { + match publication { + SealedGenerationSegmentPublicationV1::FileEvidence { digest, .. } => { + self.file_evidence.contains(digest) + } + SealedGenerationSegmentPublicationV1::CodeGraphPage { page_digest, .. } => { + self.graph_pages.contains(page_digest) + } + SealedGenerationSegmentPublicationV1::ResolutionIndex { digest, .. } => { + self.resolution_index.contains(digest) + } + SealedGenerationSegmentPublicationV1::File { .. } + | SealedGenerationSegmentPublicationV1::GenerationEvidencePage { .. } + | SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { .. } => false, + } + } +} + /// One file's sealed-segment outcome from the parallel plan: a parent /// segment reused unchanged, or fresh bytes awaiting the ordered publish. enum FileSegmentPlanV1 { @@ -951,6 +1085,74 @@ enum FileSegmentPlanV1 { Encoded(PartitionedFileSegmentDescriptorV1, Vec), } +/// The canonical parent-file reuse decision shared by cold and complete-input +/// sealing. Snapshot equality alone cannot prove parser-assigned symbol keys +/// stayed identical. The parent descriptor and extractor revision bind those +/// keys without reading or re-encoding the stored segment. +pub(super) fn reusable_file_segment( + manifest: &CodeGenerationManifestV1, + current_snapshot_file: &SanitizedCodeFileV1, + file: &FileGenerationArtifactsV1, + file_key: u32, + prior: Option<( + &CodeGenerationManifestV1, + &SanitizedCodeFileV1, + &PartitionedFileSegmentDescriptorV1, + )>, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result, CodeIndexProductionErrorV1> { + let Some((parent_manifest, prior_file, prior_descriptor)) = prior else { + return Ok(None); + }; + if prior_file != current_snapshot_file { + return Ok(None); + } + let Some(language) = current_snapshot_file.language.as_ref() else { + return Ok(None); + }; + let current_revision = manifest + .extractor_revisions + .iter() + .find(|(candidate, _)| candidate == language) + .map(|(_, revision)| revision); + let prior_revision = parent_manifest + .extractor_revisions + .iter() + .find(|(candidate, _)| candidate == language) + .map(|(_, revision)| revision); + if current_revision.is_none() || current_revision != prior_revision { + return Ok(None); + } + let symbols = &file.artifacts.symbols; + let workspace = symbols + .len() + .saturating_mul(std::mem::size_of::()) + .saturating_add( + symbols + .iter() + .map(|symbol| symbol.identity.as_str().len()) + .sum::(), + ) + // The streamed SHA state is inline; its one tagged result owns 71 bytes. + .saturating_add(71); + retain(workspace as u64)?; + let mut identities = Vec::with_capacity(symbols.len()); + identities.extend(symbols.iter().map(|symbol| symbol.identity.clone())); + identities.sort_unstable(); + identities.dedup(); + let current_digest = symbol_identities_digest(&identities)?; + let matches = current_digest == prior_descriptor.symbol_identities_digest; + drop(current_digest); + drop(identities); + retain(0)?; + if !matches { + return Ok(None); + } + let mut descriptor = prior_descriptor.clone(); + descriptor.file_key = file_key; + Ok(Some(descriptor)) +} + /// One file segment's encode buffers: the serde staging payload and the /// canonical segment. Files encode on the indexing pool, taking a cleared /// pair from `SealedEncodeBufferPoolV1` and handing the `segment` to the @@ -1164,7 +1366,28 @@ pub(super) fn write_generation_evidence( SealedGenerationSegmentPublicationV1<'_>, ) -> Result<(), CodeIndexProductionErrorV1>, ) -> Result { - let mut writer = PartitionedEvidencePageWriterV1::new(publish); + write_generation_evidence_charged(evidence, 0, &mut |_| Ok(()), publish) +} + +pub(super) fn write_generation_evidence_charged( + evidence: &PartitionedGenerationEvidenceRefV1<'_>, + base: usize, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, + publish: &mut impl FnMut( + SealedGenerationSegmentPublicationV1<'_>, + ) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result { + let length = encoded_json_rows_length(evidence)?; + let pages = length.div_ceil(GENERATION_EVIDENCE_PAGE_MAX_BYTES_V1); + // One fixed page buffer, the exact page table, each table digest, and + // the aggregate digest coexist until the writer finishes. + let workspace = GENERATION_EVIDENCE_PAGE_MAX_BYTES_V1 + .saturating_add( + pages.saturating_mul(std::mem::size_of::() + 71), + ) + .saturating_add(71); + retain((base.saturating_add(workspace)) as u64)?; + let mut writer = PartitionedEvidencePageWriterV1::with_descriptor_capacity(publish, pages); let encoded = serde_json::to_writer(&mut writer, evidence); if let Some(error) = writer.take_publish_error() { return Err(error); @@ -1322,34 +1545,184 @@ pub(super) fn inflate_index_segment( Ok(canonical) } +struct EncodedRowsLengthV1(usize); + +impl std::io::Write for EncodedRowsLengthV1 { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self + .0 + .checked_add(bytes.len()) + .ok_or_else(|| std::io::Error::other("sealed row length overflowed"))?; + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +struct ChargedEncodedRowsV1<'a> { + bytes: Vec, + base: usize, + retain: &'a mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, + refused: Option, +} + +impl std::io::Write for ChargedEncodedRowsV1<'_> { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let length = self + .bytes + .len() + .checked_add(bytes.len()) + .ok_or_else(|| std::io::Error::other("sealed row length overflowed"))?; + if length > self.bytes.capacity() { + let capacity = length.max(self.bytes.capacity().saturating_mul(2)); + let charge = self + .base + .checked_add(self.bytes.capacity()) + .and_then(|value| value.checked_add(capacity)) + .and_then(|value| u64::try_from(value).ok()) + .ok_or_else(|| std::io::Error::other("sealed row charge overflowed"))?; + if let Err(error) = (self.retain)(charge) { + self.refused = Some(error); + return Err(std::io::Error::other("sealed row admission refused")); + } + self.bytes + .try_reserve_exact(capacity - self.bytes.len()) + .map_err(std::io::Error::other)?; + if let Err(error) = + (self.retain)(self.base.saturating_add(self.bytes.capacity()) as u64) + { + self.refused = Some(error); + return Err(std::io::Error::other( + "sealed row admission adjustment failed", + )); + } + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Count the existing serde representation without allocating a JSON buffer. +pub(super) fn encoded_json_rows_length( + value: &T, +) -> Result { + let mut length = EncodedRowsLengthV1(0); + serde_json::to_writer(&mut length, value).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!("sealed row length counting failed: {error}")) + })?; + Ok(length.0) +} + +/// Workspace of one canonical `ObjectWriter`, beyond its caller's sink. +/// Its String starts at `fields * 32`; geometric growth can hold the old +/// allocation beside a new allocation of at most twice the required length. +/// Entries are `(Cow, Range)`; reserve/growth and stable key sorting +/// can additionally overlap their old/new vector and half-length sort scratch. +/// Callers sum the simultaneously buffered known DTO objects, not all rows. +pub(super) fn canonical_object_workspace_bytes(json_bytes: usize, fields: usize) -> usize { + let values = json_bytes.max(fields.saturating_mul(32)).max(8); + let entries = fields.max(4); + values + .saturating_add(values.saturating_mul(2)) + .saturating_add( + entries + .saturating_mul(3) + .saturating_add(entries.div_ceil(2)) + .saturating_mul(std::mem::size_of::<( + std::borrow::Cow<'static, str>, + std::ops::Range, + )>()), + ) +} + +/// Serialize existing typed rows after charging their exact JSON buffer. +pub(super) fn encode_json_rows_charged( + value: &T, + base: usize, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result, CodeIndexProductionErrorV1> { + let failed = |error: &dyn std::fmt::Display| { + CodeIndexProductionErrorV1::Contract(format!("sealed row encoding failed: {error}")) + }; + let length = encoded_json_rows_length(value)?; + let raw_base = base + .checked_add(length) + .ok_or_else(|| failed(&"sealed row charge overflowed"))?; + retain(u64::try_from(raw_base).map_err(|_| failed(&"sealed row charge exceeds u64"))?)?; + let mut canonical = Vec::with_capacity(length); + serde_json::to_writer(&mut canonical, value).map_err(|error| failed(&error))?; + Ok(canonical) +} + +/// The existing typed JSON/DEFLATE representation, with admission before +/// retaining its raw or compressed buffers. The counting pass allocates no +/// second row collection and preserves serde's original byte order. +pub(super) fn encode_compressed_rows_charged( + value: &T, + compression_level: u32, + base: usize, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<(Vec, u64), CodeIndexProductionErrorV1> { + let failed = |error: &dyn std::fmt::Display| { + CodeIndexProductionErrorV1::Contract(format!("sealed row encoding failed: {error}")) + }; + let canonical = encode_json_rows_charged(value, base, retain)?; + let raw_base = base + .checked_add(canonical.capacity()) + .ok_or_else(|| failed(&"sealed row charge overflowed"))?; + let mut output = ChargedEncodedRowsV1 { + bytes: Vec::new(), + base: raw_base, + retain, + refused: None, + }; + let encoded = { + let mut encoder = DeflateEncoder::new(&mut output, Compression::new(compression_level)); + encoder + .write_all(&canonical) + .and_then(|()| encoder.finish().map(|_| ())) + }; + if let Some(error) = output.refused.take() { + return Err(error); + } + encoded.map_err(|error| failed(&error))?; + let decoded_size = + u64::try_from(canonical.len()).map_err(|_| failed(&"sealed row length exceeds u64"))?; + Ok((output.bytes, decoded_size)) +} + /// One file's evidence segment: its stored bytes and their descriptor. pub(super) fn encode_file_evidence_segment( file_key: u32, file_occurrence_id: &FileOccurrenceId, evidence: &PersistedFileEvidenceV1, ) -> Result<(PartitionedFileEvidenceDescriptorV1, Vec), CodeIndexProductionErrorV1> { - let failed = |error: &dyn std::fmt::Display| { - CodeIndexProductionErrorV1::Contract(format!( - "sealed file evidence encoding failed: {error}" - )) - }; - let canonical = serde_json::to_vec(evidence).map_err(|error| failed(&error))?; - let mut encoder = - DeflateEncoder::new(Vec::new(), Compression::new(FILE_SEGMENT_COMPRESSION_LEVEL)); - encoder - .write_all(&canonical) - .map_err(|error| failed(&error))?; - let bytes = encoder.finish().map_err(|error| failed(&error))?; - let length = |bytes: &[u8]| { - u64::try_from(bytes.len()).map_err(|_| failed(&"sealed file evidence length exceeds u64")) - }; + encode_file_evidence_segment_charged(file_key, file_occurrence_id, evidence, &mut |_| Ok(())) +} + +pub(super) fn encode_file_evidence_segment_charged( + file_key: u32, + file_occurrence_id: &FileOccurrenceId, + evidence: &PersistedFileEvidenceV1, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<(PartitionedFileEvidenceDescriptorV1, Vec), CodeIndexProductionErrorV1> { + let (bytes, decoded_size_bytes) = + encode_compressed_rows_charged(evidence, FILE_SEGMENT_COMPRESSION_LEVEL, 0, retain)?; let descriptor = PartitionedFileEvidenceDescriptorV1 { file_key, file_occurrence_id: file_occurrence_id.clone(), segment_digest: ManifestDigest::from_sha256_bytes(&Sha256::digest(&bytes)) .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?, - segment_size_bytes: length(&bytes)?, - decoded_size_bytes: length(&canonical)?, + segment_size_bytes: u64::try_from(bytes.len()).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "sealed file evidence length exceeds u64".to_owned(), + ) + })?, + decoded_size_bytes, explicit_lineage: evidence.has_explicit_lineage(), }; Ok((descriptor, bytes)) @@ -2277,15 +2650,23 @@ impl SealedGenerationFileWindowsV1 { /// Serialize one partitioned manifest inside its state-digest envelope. pub(super) fn seal_partitioned_manifest( generation: &PartitionedPublishedGenerationRefV1<'_>, +) -> Result, CodeIndexProductionErrorV1> { + seal_partitioned_manifest_charged(generation, 0, &mut |_| Ok(())) +} + +pub(super) fn seal_partitioned_manifest_charged( + generation: &PartitionedPublishedGenerationRefV1<'_>, + base: usize, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, ) -> Result, CodeIndexProductionErrorV1> { let generation_bytes = { let _span = tracing::trace_span!("code_index.sealed_encode.manifest_serialize").entered(); - serde_json::to_vec(generation).map_err(|error| { - CodeIndexProductionErrorV1::Contract(format!( - "sealed generation manifest serialization failed: {error}" - )) - }) + encode_json_rows_charged(generation, base, retain) }?; + let raw_base = base + .saturating_add(generation_bytes.capacity()) + .saturating_add(71); + retain(raw_base as u64)?; let state_digest = { let _span = tracing::trace_span!("code_index.sealed_encode.manifest_digest").entered(); ManifestDigest::from_sha256_bytes(&Sha256::digest(&generation_bytes)) @@ -2301,15 +2682,14 @@ pub(super) fn seal_partitioned_manifest( )) })?) .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - serde_json::to_vec(&PartitionedEnvelopeRefV1 { - state_digest: &state_digest, - generation: &generation, - }) - .map_err(|error| { - CodeIndexProductionErrorV1::Contract(format!( - "sealed generation manifest serialization failed: {error}" - )) - }) + encode_json_rows_charged( + &PartitionedEnvelopeRefV1 { + state_digest: &state_digest, + generation: &generation, + }, + raw_base, + retain, + ) } } } @@ -2404,20 +2784,14 @@ impl CodeIndexPublishedGenerationV1 { }) .collect::>() }); - let reusable_graph_page_digests = parent.as_ref().map_or_default(|parent| { - parent - .code_graph_pages - .iter() - .map(|page| page.page_digest.clone()) - .collect::>() - }); - let reusable_index_digests = parent.as_ref().map_or_default(|parent| { - parent - .resolution_index - .segments() - .map(|page| page.segment_digest.clone()) - .collect::>() - }); + let reusable = ParentSegmentReuseRosterV1::new(parent.as_ref(), &mut |_| Ok(()))?; + let mut publish_segment = |segment: SealedGenerationSegmentPublicationV1<'_>| { + if reusable.contains(segment) { + Ok(()) + } else { + publish_segment(segment) + } + }; let file_keys = snapshot_file_keys( self.snapshot .files @@ -2444,45 +2818,13 @@ impl CodeIndexPublishedGenerationV1 { "sealed generation file key is outside its snapshot".to_owned(), ) })?; - let prior = parent_segments.get(¤t_snapshot_file.file_occurrence_id); - let current_identities_digest = prior - .map(|_| { - let mut identities = file - .artifacts - .symbols - .iter() - .map(|symbol| symbol.identity.clone()) - .collect::>(); - identities.sort(); - identities.dedup(); - symbol_identities_digest(&identities) - }) - .transpose()?; - let reused = prior - .and_then(|(prior_file, prior_descriptor)| { - (*prior_file == current_snapshot_file).then_some(())?; - let language = current_snapshot_file.language.as_ref()?; - let current_extractor_revision = self - .manifest - .extractor_revisions - .iter() - .find(|(candidate, _)| candidate == language) - .map(|(_, revision)| revision)?; - let prior_extractor_revision = parent - .as_ref()? - .manifest - .extractor_revisions - .iter() - .find(|(candidate, _)| candidate == language) - .map(|(_, revision)| revision)?; - (prior_extractor_revision == current_extractor_revision).then_some(())?; - (current_identities_digest.as_ref() - == Some(&prior_descriptor.symbol_identities_digest)) - .then_some(())?; - let mut descriptor = (*prior_descriptor).clone(); - descriptor.file_key = key; - Some(descriptor) - }); + let prior = parent.as_ref().and_then(|parent| { + parent_segments.get(¤t_snapshot_file.file_occurrence_id) + .map(|(prior_file, descriptor)| (&parent.manifest, *prior_file, *descriptor)) + }); + let reused = reusable_file_segment( + &self.manifest, current_snapshot_file, file, key, prior, &mut |_| Ok(()), + )?; if let Some(descriptor) = reused { return Ok(FileSegmentPlanV1::Reused(descriptor)); } @@ -2528,20 +2870,9 @@ impl CodeIndexPublishedGenerationV1 { } } file_segments.sort_by_key(|segment| segment.file_key); - let reusable_file_evidence_digests = parent.as_ref().map_or_default(|parent| { - parent - .file_evidence - .iter() - .map(|evidence| evidence.segment_digest.clone()) - .collect::>() - }); let (lineage_prior_generation, file_evidence) = { let _span = tracing::trace_span!("code_index.sealed_encode.file_evidence").entered(); - self.encode_file_evidence( - &file_keys, - reusable_file_evidence_digests, - &mut publish_segment, - ) + self.encode_file_evidence(&file_keys, BTreeSet::new(), &mut publish_segment) }?; let rosters = FileChunkRostersV1::new( self.files @@ -2575,24 +2906,14 @@ impl CodeIndexPublishedGenerationV1 { self.for_each_sealed_code_graph_page(|page| { code_graph_pages.push(publish_code_graph_page( page, - &reusable_graph_page_digests, + &BTreeSet::new(), &mut publish_segment, )?); Ok(()) }) } }?; - let resolution_index = seal_resolution_index( - &self.files, - &mut |publication: SealedGenerationSegmentPublicationV1<'_>| match publication { - SealedGenerationSegmentPublicationV1::ResolutionIndex { digest, .. } - if reusable_index_digests.contains(digest) => - { - Ok(()) - } - publication => publish_segment(publication), - }, - )?; + let resolution_index = seal_resolution_index(&self.files, &mut publish_segment)?; let statistics = self.generation_statistics()?; seal_partitioned_manifest(&PartitionedPublishedGenerationRefV1 { format_revision: SEALED_GENERATION_FORMAT_REVISION_V1, @@ -2783,15 +3104,18 @@ impl CodeIndexPublishedGenerationV1 { pub fn partitioned_text_metadata( bytes: &[u8], ) -> Result { - Self::partitioned_metadata_and_lane(bytes).map(|(metadata, _)| metadata) + Self::partitioned_metadata_and_lane(bytes).map(|(metadata, _, _)| metadata) } /// [`Self::partitioned_text_metadata`] with the generation's lane digest. pub(super) fn partitioned_metadata_and_lane( bytes: &[u8], - ) -> Result<(VerifiedSealedTextGenerationMetadataV1, ManifestDigest), CodeIndexProductionErrorV1> - { + ) -> Result< + (VerifiedSealedTextGenerationMetadataV1, ManifestDigest, u64), + CodeIndexProductionErrorV1, + > { let generation = parse_partitioned_manifest(bytes)?; + let chunk_count = generation.chunk_count; let lane = super::sparse_increment::lane_digest( &generation.snapshot, &generation.file_segments, @@ -2805,7 +3129,7 @@ impl CodeIndexPublishedGenerationV1 { generation.chunk_policy, sources, )?; - Ok((metadata, lane)) + Ok((metadata, lane, chunk_count)) } pub fn partitioned_segment_identities( diff --git a/crates/tracedecay-code-index/src/production/projection_rows.rs b/crates/tracedecay-code-index/src/production/projection_rows.rs index 7703805350..b04bc855b9 100644 --- a/crates/tracedecay-code-index/src/production/projection_rows.rs +++ b/crates/tracedecay-code-index/src/production/projection_rows.rs @@ -18,13 +18,15 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use tracedecay_domain::{ - ChangedCodeChunkSetV1, ChangedCodeChunkV1, CodeChunkProjectionReceiptV1, CodeGenerationId, - CodeSearchChunkId, CodeSearchChunkV1, ContentDigest, ManifestDigest, ProjectionBatchReceiptV1, + ChangedCodeChunkSetV1, ChangedCodeChunkV1, CodeGenerationId, CodeSearchChunkId, + CodeSearchChunkV1, ContentDigest, ManifestDigest, ProjectionBatchReceiptV1, ProjectionBatchRequestV1, ProjectionKeyV1, ProjectionOperationV1, ProjectionOutcomeV1, ProjectionReplayReasonV1, }; -use super::CodeIndexProductionErrorV1; +use super::{CodeIndexProductionErrorV1, resident_bytes}; +use crate::projection::ProjectionPublicationHandoffV1; +use crate::receipts::{applied_operation, applied_receipt, ordered_changes}; fn contract(message: &str) -> CodeIndexProductionErrorV1 { CodeIndexProductionErrorV1::Contract(message.to_owned()) @@ -33,7 +35,7 @@ fn contract(message: &str) -> CodeIndexProductionErrorV1 { /// Each file's chunks in chunk-id order, by the file's snapshot key. Encoding /// and restore both derive it from the file artifacts, so positions agree. pub(super) struct FileChunkRostersV1<'a> { - files: BTreeMap>, + files: BTreeMap>, } impl<'a> FileChunkRostersV1<'a> { @@ -41,19 +43,88 @@ impl<'a> FileChunkRostersV1<'a> { Self { files: files .map(|(file_key, chunks)| { - let mut roster = chunks.iter().map(Arc::as_ref).collect::>(); - roster.sort_by(|left, right| left.id.cmp(&right.id)); + let mut roster = chunks + .iter() + .map(|chunk| (&chunk.id, &chunk.content_digest)) + .collect::>(); + roster.sort_by(|left, right| left.0.cmp(right.0)); (file_key, roster) }) .collect(), } } + /// Cold construction owns only authenticated change rows and their file keys. + /// Admission precedes both a new tree entry and a roster reallocation. + pub(super) fn from_changes( + changes: &'a [ChangedCodeChunkV1], + file_keys: &[u32], + base: usize, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result { + if changes.len() != file_keys.len() { + return Err(contract("cold chunk membership and change rows disagree")); + } + let mut files = BTreeMap::>::new(); + let mut slots = 0_usize; + for (change, key) in changes.iter().zip(file_keys) { + let digest = change + .current_digest + .as_ref() + .ok_or_else(|| contract("cold chunk has no content digest"))?; + if !files.contains_key(key) { + retain( + (base + .saturating_add(slots) + .saturating_add(roster_tree_bytes(files.len() + 1))) + as u64, + )?; + } + let tree_bytes = roster_tree_bytes(files.len() + usize::from(!files.contains_key(key))); + let roster = files.entry(*key).or_default(); + if roster.len() == roster.capacity() { + let capacity = roster.capacity().saturating_mul(2).max(1); + let new_bytes = capacity + .saturating_mul(std::mem::size_of::<(&CodeSearchChunkId, &ContentDigest)>()); + // The old allocation can remain live while realloc obtains the new one. + retain( + (base + .saturating_add(tree_bytes) + .saturating_add(slots) + .saturating_add(new_bytes)) as u64, + )?; + let old_bytes = roster + .capacity() + .saturating_mul(std::mem::size_of::<(&CodeSearchChunkId, &ContentDigest)>()); + roster + .try_reserve_exact(capacity - roster.len()) + .map_err(|error| contract(&error.to_string()))?; + slots = + slots.saturating_sub(old_bytes).saturating_add( + roster.capacity().saturating_mul(std::mem::size_of::<( + &CodeSearchChunkId, + &ContentDigest, + )>()), + ); + } + roster.push((&change.chunk_id, digest)); + } + let rosters = Self { files }; + retain((base.saturating_add(rosters.retained_bytes())) as u64)?; + Ok(rosters) + } + + pub(super) fn retained_bytes(&self) -> usize { + self.files.values().fold(roster_tree_bytes(self.files.len()), |bytes, roster| { + bytes.saturating_add(roster.capacity().saturating_mul(std::mem::size_of::<(&CodeSearchChunkId, &ContentDigest)>())) + }) + } + fn chunk( &self, file_key: u32, position: u32, - ) -> Result<&'a CodeSearchChunkV1, CodeIndexProductionErrorV1> { + ) -> Result<(&'a CodeSearchChunkId, &'a ContentDigest), CodeIndexProductionErrorV1> { usize::try_from(position) .ok() .and_then(|position| self.files.get(&file_key)?.get(position)) @@ -62,6 +133,13 @@ impl<'a> FileChunkRostersV1<'a> { } } +fn roster_tree_bytes(files: usize) -> usize { + resident_bytes::btree_bytes( + files, + std::mem::size_of::<(u32, Vec<(&CodeSearchChunkId, &ContentDigest)>)>(), + ) +} + #[derive(Serialize)] pub(super) struct PersistedProjectionRequestRefV1<'a> { request_digest: &'a ManifestDigest, @@ -124,19 +202,47 @@ impl<'a> PersistedProjectionRequestRefV1<'a> { pub(super) fn new( request: &'a ProjectionBatchRequestV1, rosters: &FileChunkRostersV1<'_>, + base: usize, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, ) -> Result { - let mut positions = HashMap::new(); + let count = rosters.files.values().map(Vec::len).sum::(); + let table_bytes = resident_bytes::hash_table_bytes::<( + &CodeSearchChunkId, + (u32, u32, &ContentDigest), + )>(super::helpers::resolution_hash_capacity(count)); + retain((base.saturating_add(table_bytes)) as u64)?; + let mut positions = HashMap::with_capacity(count); for (file_key, roster) in &rosters.files { for (position, chunk) in roster.iter().enumerate() { let position = u32::try_from(position) .map_err(|_| contract("sealed file chunk roster exceeds u32"))?; - positions.insert(&chunk.id, (*file_key, position, &chunk.content_digest)); + positions.insert(chunk.0, (*file_key, position, chunk.1)); } } let changes = &request.changes; - let mut added = Vec::new(); - let mut changed = Vec::new(); - let mut rows = Vec::new(); + let mut counts = [0_usize; 3]; + for change in &changes.added_or_changed { + let located = positions + .get(&change.chunk_id) + .is_some_and(|(_, _, digest)| change.current_digest.as_ref() == Some(*digest)); + counts[if !located { + 2 + } else if change.prior_digest.is_some() { + 1 + } else { + 0 + }] += 1; + } + let vectors = counts[0] + .saturating_mul(std::mem::size_of::<(u32, u32)>() + std::mem::size_of::()) + .saturating_add( + counts[1].saturating_mul(std::mem::size_of::<(u32, u32, &ContentDigest)>()), + ) + .saturating_add(counts[2].saturating_mul(std::mem::size_of::<&ChangedCodeChunkV1>())); + retain((base.saturating_add(table_bytes).saturating_add(vectors)) as u64)?; + let mut added = Vec::with_capacity(counts[0]); + let mut changed = Vec::with_capacity(counts[1]); + let mut rows = Vec::with_capacity(counts[2]); for change in &changes.added_or_changed { let located = positions .get(&change.chunk_id) @@ -150,8 +256,8 @@ impl<'a> PersistedProjectionRequestRefV1<'a> { } } added.sort_unstable(); - changed.sort_by_key(|(file_key, position, _)| (*file_key, *position)); - let mut runs: Vec = Vec::new(); + changed.sort_unstable_by_key(|(file_key, position, _)| (*file_key, *position)); + let mut runs: Vec = Vec::with_capacity(counts[0]); for (file_key, position) in added { match runs.last_mut() { Some((run_file, start, count)) @@ -162,7 +268,8 @@ impl<'a> PersistedProjectionRequestRefV1<'a> { _ => runs.push((file_key, position, 1)), } } - Ok(Self { + drop(positions); + let result = Self { request_digest: &request.request_digest, changes: PersistedChangeSetRefV1 { from_generation: &changes.from_generation, @@ -178,7 +285,28 @@ impl<'a> PersistedProjectionRequestRefV1<'a> { previous_projection_key: &request.previous_projection_key, target_projection_key: &request.target_projection_key, replay_reason: request.replay_reason, - }) + }; + retain((base.saturating_add(result.retained_bytes())) as u64)?; + Ok(result) + } + + pub(super) fn retained_bytes(&self) -> usize { + self.changes + .added + .capacity() + .saturating_mul(std::mem::size_of::()) + .saturating_add( + self.changes + .changed + .capacity() + .saturating_mul(std::mem::size_of::<(u32, u32, &ContentDigest)>()), + ) + .saturating_add( + self.changes + .rows + .capacity() + .saturating_mul(std::mem::size_of::<&ChangedCodeChunkV1>()), + ) } } @@ -200,18 +328,18 @@ impl PersistedProjectionRequestV1 { for position in start..end { let chunk = rosters.chunk(file_key, position)?; added_or_changed.push(ChangedCodeChunkV1 { - chunk_id: chunk.id.clone(), + chunk_id: chunk.0.clone(), prior_digest: None, - current_digest: Some(chunk.content_digest.clone()), + current_digest: Some(chunk.1.clone()), }); } } for (file_key, position, prior) in changes.changed { let chunk = rosters.chunk(file_key, position)?; added_or_changed.push(ChangedCodeChunkV1 { - chunk_id: chunk.id.clone(), + chunk_id: chunk.0.clone(), prior_digest: Some(prior), - current_digest: Some(chunk.content_digest.clone()), + current_digest: Some(chunk.1.clone()), }); } added_or_changed.extend(changes.rows); @@ -251,7 +379,7 @@ pub(super) struct PersistedBatchReceiptRefV1<'a> { struct PersistedChunkReceiptRefV1<'a> { chunk_id: &'a CodeSearchChunkId, operation: ProjectionOperationV1, - outcome: &'a ProjectionOutcomeV1, + outcome: ProjectionOutcomeV1, #[serde(skip_serializing_if = "Option::is_none")] output_digest: Option<&'a ContentDigest>, } @@ -278,71 +406,46 @@ struct PersistedChunkReceiptV1 { output_digest: Option, } -/// The request's rows in the chunk order a receipt answers them in. -fn answered_rows( - request: &ProjectionBatchRequestV1, -) -> BTreeMap<&CodeSearchChunkId, &ChangedCodeChunkV1> { - request - .changes - .added_or_changed - .iter() - .chain(&request.changes.deleted) - .map(|change| (&change.chunk_id, change)) - .collect() -} +impl<'a> PersistedBatchReceiptRefV1<'a> { + pub(super) fn allocation_bytes(projection: &ProjectionPublicationHandoffV1) -> usize { + projection + .output_overrides() + .len() + .saturating_mul(std::mem::size_of::>()) + } -/// The operation a receipt must record for `change`, with the outcome and -/// output a projector that applied it as requested records. -fn applied(change: &ChangedCodeChunkV1) -> ProjectionOperationV1 { - match (&change.prior_digest, &change.current_digest) { - (_, None) => ProjectionOperationV1::Deleted, - (None, Some(_)) => ProjectionOperationV1::Added, - (Some(_), Some(_)) => ProjectionOperationV1::Updated, + pub(super) fn allocation_bytes_held(&self) -> usize { + self.exceptions + .capacity() + .saturating_mul(std::mem::size_of::>()) } -} -impl<'a> PersistedBatchReceiptRefV1<'a> { - pub(super) fn new( - request: &ProjectionBatchRequestV1, - receipt: &'a ProjectionBatchReceiptV1, - ) -> Result { - let rows = answered_rows(request); - if receipt.receipts.len() != rows.len() - || receipt - .receipts - .iter() - .zip(rows.keys()) - .any(|(receipt, chunk_id)| &receipt.chunk_id != *chunk_id) - { - return Err(contract( - "sealed projection receipt does not answer its request rows in chunk order", - )); - } - let exceptions = receipt - .receipts - .iter() - .zip(rows.values()) - .filter(|(receipt, change)| { - receipt.operation != applied(change) - || receipt.outcome != ProjectionOutcomeV1::Applied - || receipt.output_digest != change.current_digest - }) - .map(|(receipt, _)| PersistedChunkReceiptRefV1 { - chunk_id: &receipt.chunk_id, - operation: receipt.operation, - outcome: &receipt.outcome, - output_digest: receipt.output_digest.as_ref(), - }) - .collect(); - Ok(Self { - target_projection_key: &receipt.target_projection_key, - request_digest: &receipt.request_digest, - source_generation: &receipt.source_generation, - source_manifest_digest: &receipt.source_manifest_digest, + pub(super) fn new(projection: &'a ProjectionPublicationHandoffV1) -> Self { + let mut overrides = projection.output_overrides().iter().peekable(); + let mut exceptions = Vec::with_capacity(projection.output_overrides().len()); + exceptions.extend( + ordered_changes(&projection.request().changes) + .enumerate() + .filter_map(|(index, change)| { + overrides + .next_if(|(position, _)| *position == index) + .map(|(_, output)| PersistedChunkReceiptRefV1 { + chunk_id: &change.chunk_id, + operation: applied_operation(change), + outcome: ProjectionOutcomeV1::Applied, + output_digest: Some(output), + }) + }), + ); + Self { + target_projection_key: projection.target_projection_key(), + request_digest: projection.request_digest(), + source_generation: projection.source_generation(), + source_manifest_digest: projection.source_manifest_digest(), exceptions, - reused_count: receipt.reused_count, - publication_digest: &receipt.publication_digest, - }) + reused_count: projection.reused_count(), + publication_digest: projection.publication_digest(), + } } } @@ -354,39 +457,31 @@ impl PersistedBatchReceiptV1 { self, request: &ProjectionBatchRequestV1, ) -> Result { - let rows = answered_rows(request); - let mut exceptions = self - .exceptions - .into_iter() - .map(|exception| (exception.chunk_id.clone(), exception)) - .collect::>(); + let mut exceptions = HashMap::new(); + for exception in self.exceptions { + if exceptions + .insert(exception.chunk_id.clone(), exception) + .is_some() + { + return Err(contract( + "sealed generation receipt repeats a chunk exception", + )); + } + } + let rows = ordered_changes(&request.changes); let mut receipts = Vec::with_capacity(rows.len()); - for (chunk_id, change) in rows { - let (operation, outcome, output_digest) = match exceptions.remove(chunk_id) { - Some(exception) => ( - exception.operation, - exception.outcome, - exception.output_digest, - ), - None => ( - applied(change), - ProjectionOutcomeV1::Applied, - change.current_digest.clone(), - ), - }; - receipts.push(CodeChunkProjectionReceiptV1 { - projection_key: self.target_projection_key.clone(), - request_digest: self.request_digest.clone(), - prior_generation: request.changes.from_generation.clone(), - source_generation: self.source_generation.clone(), - source_manifest_digest: self.source_manifest_digest.clone(), - chunk_id: chunk_id.clone(), - prior_chunk_digest: change.prior_digest.clone(), - current_chunk_digest: change.current_digest.clone(), - operation, - outcome, - output_digest, - }); + for change in rows { + let mut receipt = applied_receipt(request, change); + receipt.projection_key = self.target_projection_key.clone(); + receipt.request_digest = self.request_digest.clone(); + receipt.source_generation = self.source_generation.clone(); + receipt.source_manifest_digest = self.source_manifest_digest.clone(); + if let Some(exception) = exceptions.remove(&change.chunk_id) { + receipt.operation = exception.operation; + receipt.outcome = exception.outcome; + receipt.output_digest = exception.output_digest; + } + receipts.push(receipt); } if !exceptions.is_empty() { return Err(contract( @@ -404,3 +499,89 @@ impl PersistedBatchReceiptV1 { }) } } + +#[cfg(test)] +mod tests { + use super::{PersistedBatchReceiptRefV1, PersistedBatchReceiptV1}; + use crate::projection::{ProjectionPublicationHandoffV1, project_for_publication}; + use crate::receipts::{ + ChunkProjectionDecisionV1, build_batch_receipt, expected_request_digest, + }; + use tracedecay_domain::{ + ChangedCodeChunkSetV1, ContentDigest, ProjectionBatchRequestV1, ProjectionOperationV1, + ProjectionOutcomeV1, + }; + + fn request() -> ProjectionBatchRequestV1 { + let digest = format!("sha256:{}", "a".repeat(64)); + let mut request: ProjectionBatchRequestV1 = serde_json::from_value(serde_json::json!({ + "request_digest": digest, + "changes": { + "from_generation": null, + "to_generation": "generation.v1.aaaaaaaa.00000001", + "manifest_digest": digest, + "added_or_changed": [{"chunk_id": "chunk.v1.added", "prior_digest": null, "current_digest": digest}], + "deleted": [], "reused_count": 0, "reused_digest": digest + }, + "previous_projection_key": null, + "target_projection_key": {"kind": "lexical", "schema_revision": "lexical.v1", "profile_digest": digest}, + "replay_reason": "initial_projection" + })).expect("request fixture"); + request.changes.reused_digest = ChangedCodeChunkSetV1::seal_reused_partition(&[]) + .expect("empty reuse") + .1; + request.changes.seal().expect("changes seal"); + request.request_digest = expected_request_digest(&request).expect("request seal"); + request + } + + #[test] + fn compact_receipt_wire_restores_nondefault_outputs_and_rejects_exception_corruption() { + let request = request(); + let change = &request.changes.added_or_changed[0]; + let output = + ContentDigest::new(format!("sha256:{}", "f".repeat(64))).expect("output digest"); + let expanded = build_batch_receipt( + &request, + &[ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: None, + current_chunk_digest: change.current_digest.clone(), + operation: ProjectionOperationV1::Added, + outcome: ProjectionOutcomeV1::Applied, + output_digest: Some(output), + }], + ) + .expect("expanded evidence"); + let handoff = ProjectionPublicationHandoffV1::restore(request.clone(), expanded.clone()) + .expect("verified handoff"); + let wire = serde_json::to_value(PersistedBatchReceiptRefV1::new(&handoff)) + .expect("persist compact receipt"); + let decoded: PersistedBatchReceiptV1 = + serde_json::from_value(wire.clone()).expect("decode"); + assert_eq!(decoded.expand(&request).expect("restore rows"), expanded); + + let mut duplicate = wire.clone(); + let exceptions = duplicate["exceptions"].as_array_mut().expect("exceptions"); + assert_eq!(exceptions.len(), 1); + exceptions.push(exceptions[0].clone()); + let duplicate: PersistedBatchReceiptV1 = + serde_json::from_value(duplicate).expect("duplicate wire"); + assert!(duplicate.expand(&request).is_err()); + + let mut extra = wire; + extra["exceptions"][0]["chunk_id"] = serde_json::json!("chunk.v1.foreign"); + let extra: PersistedBatchReceiptV1 = serde_json::from_value(extra).expect("foreign wire"); + assert!(extra.expand(&request).is_err()); + + let default = project_for_publication(request).expect("default publication"); + let default_wire = serde_json::to_value(PersistedBatchReceiptRefV1::new(&default)) + .expect("default compact receipt"); + assert!( + default_wire["exceptions"] + .as_array() + .expect("default exceptions") + .is_empty() + ); + } +} diff --git a/crates/tracedecay-code-index/src/production/resident_bytes.rs b/crates/tracedecay-code-index/src/production/resident_bytes.rs index 227ff43d9a..d486d48ee1 100644 --- a/crates/tracedecay-code-index/src/production/resident_bytes.rs +++ b/crates/tracedecay-code-index/src/production/resident_bytes.rs @@ -11,10 +11,12 @@ use std::mem::size_of; use std::sync::Arc; -use tracedecay_code_extraction::{GoMethodSetRowV1, GoMethodSignatureV1, GoTypeTokenV1, GoTypeV1}; +use tracedecay_code_extraction::{ + GoMethodSetRowV1, GoMethodSignatureV1, GoTypeTokenV1, GoTypeV1, ImportReexportScopeV1, +}; use tracedecay_domain::{ - CanonicalRelationEdgeV1, ChangedCodeChunkV1, CodeChunkProjectionReceiptV1, CodeSearchChunkV1, - ExactTechnicalTermV1, ProjectionKeyV1, SanitizedCodeFileV1, + CanonicalRelationEdgeV1, ChangedCodeChunkV1, CodeSearchChunkV1, ExactTechnicalTermV1, + ProjectionKeyV1, SanitizedCodeFileV1, SymbolOccurrenceId, }; use super::{ @@ -25,6 +27,7 @@ use crate::chunks::{ CodeIndexUnresolvedReferenceV1, CodeSearchEligibilityV1, }; use crate::lineage::{LineageSymbolRecordV1, SymbolLineageCandidateV1}; +use crate::projection::ProjectionPublicationHandoffV1; /// An `Arc` allocation: two reference counts before the value. pub(crate) const ARC_HEADER_BYTES: usize = 2 * size_of::(); @@ -54,6 +57,61 @@ const SWISS_GROUP_WIDTH: usize = if cfg!(all( size_of::() }; +/// The pinned SwissTable capacity class, including its entry-size-dependent +/// minimum allocation. Callers reserve this class before asking std to grow. +pub(super) fn hash_table_capacity( + requested: usize, +) -> Result { + if requested == 0 { + return Ok(0); + } + let minimum = match (SWISS_GROUP_WIDTH, size_of::()) { + (16, 0..=1) => 14, + (16, 2..=3) | (8, 0..=1) => 7, + _ => 3, + }; + let requested = requested.max(minimum); + let buckets = if requested < 4 { + 4 + } else if requested < 8 { + 8 + } else if requested < 15 { + 16 + } else { + requested + .checked_mul(8) + .and_then(|value| (value / 7).checked_next_power_of_two()) + .ok_or_else(|| { + super::CodeIndexProductionErrorV1::Contract( + "resolution hash capacity overflowed".to_owned(), + ) + })? + }; + // Validate the same padded entry/control layout before callers compute + // its byte charge or ask the allocator for it. + let layout_error = |error| { + super::CodeIndexProductionErrorV1::Contract(format!( + "resolution hash layout is invalid: {error}" + )) + }; + let entries = std::alloc::Layout::array::(buckets).map_err(layout_error)?; + let controls = buckets.checked_add(SWISS_GROUP_WIDTH).ok_or_else(|| { + super::CodeIndexProductionErrorV1::Contract("resolution hash layout overflowed".to_owned()) + })?; + let controls = std::alloc::Layout::array::(controls).map_err(layout_error)?; + let _layout = entries + .align_to(SWISS_GROUP_WIDTH) + .map_err(layout_error)? + .pad_to_align() + .extend(controls) + .map_err(layout_error)?; + Ok(if buckets < 8 { + buckets - 1 + } else { + buckets / 8 * 7 + }) +} + /// The table a std `HashMap` or `HashSet` (with `T = (K, V)` or the /// element) of the given `capacity()` allocates. std's map is a SwissTable /// whose capacity is all but one of a power-of-two bucket count below eight @@ -73,14 +131,27 @@ pub(crate) fn hash_table_bytes(capacity: usize) -> usize { (size_of::() * buckets).next_multiple_of(control_align) + buckets + SWISS_GROUP_WIDTH } -/// A `BTreeMap` allocates leaves of eleven slots (and internal nodes above -/// them); a leaf is, on average, two-thirds full once the map is built. +/// Allocation bound for the workspace's word-aligned BTreeMap/Set entries. +/// std's degree-six tree has eleven key/value slots per node, at least five +/// occupied slots in every non-root node, and at least one in the root. +/// Leaf headers fit two words; tuple entry sizes also cover separate-array +/// padding. Internal nodes additionally hold twelve child pointers. With N +/// nodes, at most (N + 3) / 6 are internal (two root children, six thereafter). +/// Unlike an average occupancy estimate, this admits the first leaf and splits +/// before they allocate, including the mostly empty root of a small map. pub(crate) fn btree_bytes(entries: usize, entry_bytes: usize) -> usize { - entries - .saturating_mul(3) - .div_ceil(2) - .saturating_mul(entry_bytes) - .saturating_add(size_of::().saturating_mul(4)) + if entries == 0 { + return 0; + } + let nodes = entries.saturating_sub(1) / 5 + 1; + let leaf = size_of::() + .saturating_mul(2) + .saturating_add(entry_bytes.saturating_mul(11)); + nodes.saturating_mul(leaf).saturating_add( + (nodes.saturating_add(3) / 6) + .saturating_mul(12) + .saturating_mul(size_of::()), + ) } pub(super) fn chunk_bytes(chunk: &CodeSearchChunkV1) -> usize { @@ -150,13 +221,17 @@ pub(crate) fn symbol_heap_bytes(symbol: &LineageSymbolRecordV1) -> usize { } pub(super) fn edge_heap_bytes(edge: &CanonicalRelationEdgeV1) -> usize { - edge.from_occurrence - .as_str() - .len() - .saturating_add(edge.to_occurrence.as_str().len()) + edge_endpoint_heap_bytes(&edge.from_occurrence, &edge.to_occurrence) } -fn abstention_heap_bytes(abstention: &CodeIndexEdgeAbstentionV1) -> usize { +pub(super) fn edge_endpoint_heap_bytes( + from: &SymbolOccurrenceId, + to: &SymbolOccurrenceId, +) -> usize { + from.as_str().len().saturating_add(to.as_str().len()) +} + +pub(super) fn abstention_heap_bytes(abstention: &CodeIndexEdgeAbstentionV1) -> usize { abstention .source_node_id .capacity() @@ -171,6 +246,10 @@ pub(crate) fn import_heap_bytes(import: &CodeIndexImportEvidenceV1) -> usize { .saturating_add(import.module_specifier.capacity()) .saturating_add(opt(import.imported_name.as_deref())) .saturating_add(opt(import.local_name.as_deref())) + .saturating_add(match &import.reexport_scope { + Some(ImportReexportScopeV1::Module(module)) => module.capacity(), + _ => 0, + }) } pub(crate) fn unresolved_heap_bytes(reference: &CodeIndexUnresolvedReferenceV1) -> usize { @@ -181,7 +260,7 @@ pub(crate) fn unresolved_heap_bytes(reference: &CodeIndexUnresolvedReferenceV1) .saturating_add(reference.reference_name.capacity()) } -fn go_method_set_heap_bytes(row: &CodeIndexGoMethodSetRowV1) -> usize { +pub(super) fn go_method_set_heap_bytes(row: &CodeIndexGoMethodSetRowV1) -> usize { fn type_bytes(ty: &GoTypeV1) -> usize { vec_bytes(ty, ty.capacity(), |token| match token { GoTypeTokenV1::Text(text) | GoTypeTokenV1::Local(text) => text.capacity(), @@ -258,7 +337,7 @@ fn projection_key_bytes(key: &ProjectionKeyV1) -> usize { .saturating_add(key.profile_digest.as_str().len()) } -fn changed_chunk_heap_bytes(change: &ChangedCodeChunkV1) -> usize { +pub(super) fn changed_chunk_heap_bytes(change: &ChangedCodeChunkV1) -> usize { change .chunk_id .as_str() @@ -273,25 +352,222 @@ fn changed_chunk_heap_bytes(change: &ChangedCodeChunkV1) -> usize { .map(|digest| digest.as_str()))) } -fn receipt_heap_bytes(receipt: &CodeChunkProjectionReceiptV1) -> usize { - projection_key_bytes(&receipt.projection_key) - .saturating_add(receipt.request_digest.as_str().len()) - .saturating_add(opt(receipt.prior_generation.as_ref().map(|id| id.as_str()))) - .saturating_add(receipt.source_generation.as_str().len()) - .saturating_add(receipt.source_manifest_digest.as_str().len()) - .saturating_add(receipt.chunk_id.as_str().len()) - .saturating_add(opt(receipt - .prior_chunk_digest - .as_ref() - .map(|digest| digest.as_str()))) - .saturating_add(opt(receipt - .current_chunk_digest +/// Heap cloned by one replayed public receipt; rows are consumed one at a time. +pub(super) fn projection_receipt_heap_bound( + request: &tracedecay_domain::ProjectionBatchRequestV1, +) -> usize { + let header = projection_key_bytes(&request.target_projection_key) + .saturating_add(request.request_digest.as_str().len()) + .saturating_add(opt(request + .changes + .from_generation .as_ref() - .map(|digest| digest.as_str()))) - .saturating_add(opt(receipt - .output_digest + .map(|id| id.as_str()))) + .saturating_add(request.changes.to_generation.as_str().len()) + .saturating_add(request.changes.manifest_digest.as_str().len()); + request + .changes + .added_or_changed + .iter() + .chain(&request.changes.deleted) + .map(|change| { + header + .saturating_add(changed_chunk_heap_bytes(change)) + .saturating_add(opt(change + .current_digest + .as_ref() + .map(|digest| digest.as_str()))) + // The order verifier holds the preceding chunk id beside this row. + .saturating_add(change.chunk_id.as_str().len()) + }) + .max() + .unwrap_or(0) +} + +/// Workspace for cloning the already pinned intake registry, validating its +/// three lookup rosters, and emitting a capability. All variable terms come +/// from those descriptors; no complete compiled registry is reconstructed. +pub(super) fn capability_construction_workspace( + descriptors: &[&tracedecay_domain::LanguageDescriptorV1], + snapshot: &tracedecay_domain::SanitizedCodeSnapshotV1, + manifest: &tracedecay_domain::CodeGenerationManifestV1, +) -> usize { + let mut descriptor_heap = 0_usize; + let mut lookup_heap = 0_usize; + let mut aliases = 0_usize; + let mut extensions = 0_usize; + for descriptor in descriptors { + descriptor_heap = descriptor_heap + .saturating_add(descriptor.language.as_str().len()) + .saturating_add(descriptor.descriptor_revision.as_str().len()) + .saturating_add(descriptor.grammar_revision.as_str().len()) + .saturating_add(descriptor.extractor_revision.as_str().len()); + for values in [ + &descriptor.aliases, + &descriptor.extensions, + &descriptor.root_markers, + ] { + descriptor_heap = descriptor_heap + .saturating_add(values.len().saturating_mul(size_of::())) + .saturating_add(values.iter().map(String::len).sum::()); + } + aliases = aliases.saturating_add(descriptor.aliases.len()); + extensions = extensions.saturating_add(descriptor.extensions.len()); + lookup_heap = lookup_heap + .saturating_add(descriptor.language.as_str().len()) + .saturating_add(descriptor.aliases.iter().map(String::len).sum::()) + .saturating_add(descriptor.extensions.iter().map(String::len).sum::()); + } + let tables = + [descriptors.len(), aliases, extensions] + .into_iter() + .fold(0_usize, |bytes, count| { + bytes + .saturating_add( + hash_table_bytes::<(String, usize)>( + super::helpers::resolution_hash_capacity(count), + ) + // Growing maps can retain their previous table during rehash. + .saturating_mul(2), + ) + .saturating_add(btree_bytes(count, size_of::())) + }); + let receipts = vec_bytes( + &snapshot.sanitization_receipts, + snapshot.sanitization_receipts.len(), + |id| id.as_str().len(), + ); + let emitter_lists = crate::capabilities::BASE_EXACT_TERM_KINDS + .len() + .saturating_mul(size_of::()) + .saturating_add( + crate::capabilities::BASE_EDGE_AUTHORITY_CLASSES + .len() + .saturating_mul(size_of::()), + ); + let emitted = descriptors.iter().fold(0_usize, |bytes, descriptor| { + bytes + .saturating_add(size_of::()) + .saturating_add(size_of::()) + .saturating_add(descriptor.language.as_str().len()) + .saturating_add(descriptor.descriptor_revision.as_str().len()) + }); + descriptors + .len() + .saturating_add(descriptors.len().div_ceil(2)) + .saturating_mul(size_of::()) + .saturating_add(descriptor_heap) + .saturating_add(tables) + // Validation owns normalized keys beside lookup keys. Static descriptor + // keys are ASCII; these are the same pinned inputs intake admitted. + .saturating_add(lookup_heap.saturating_mul(3)) + .saturating_add(receipts.saturating_mul(3)) + .saturating_add(emitter_lists.saturating_mul(2)) + .saturating_add(emitted) + .saturating_add( + descriptors + .len() + .saturating_mul(2) + .saturating_mul(size_of::<&tracedecay_domain::LanguageDescriptorV1>()), + ) + .saturating_add( + tracedecay_domain::CodeSearchChunkGrainV1::ORDER + .len() + .saturating_mul(2) + .saturating_mul(size_of::()), + ) + .saturating_add( + crate::capabilities::CHUNK_SCHEMA_REVISION_V1 + .len() + .saturating_mul(2), + ) + .saturating_add(manifest.generation_id.as_str().len()) + .saturating_add(manifest.chunker_revision.as_str().len()) + .saturating_add(manifest.privacy_domain.as_str().len()) + // Registry revision derivation owns the tagged hash, sixteen hex + // characters and the prefixed revision concurrently; capability + // construction also replaces one placeholder tagged digest. + .saturating_add(71 * 2 + 16 * 2 + ("registry.v1.".len() + 16) * 2) +} + +pub(super) fn capability_heap_bytes( + capability: &tracedecay_domain::CodeIndexCapabilityManifestV1, +) -> usize { + capability + .generation_id + .as_str() + .len() + .saturating_add(capability.chunk_schema_revision.capacity()) + .saturating_add(capability.chunker_revision.as_str().len()) + .saturating_add(capability.privacy_domain.as_str().len()) + .saturating_add(capability.manifest_digest.as_str().len()) + .saturating_add(vec_bytes( + &capability.language_descriptor_revisions, + capability.language_descriptor_revisions.capacity(), + |id| id.as_str().len(), + )) + .saturating_add(vec_bytes( + &capability.available_grains, + capability.available_grains.capacity(), + |_| 0, + )) + .saturating_add(vec_bytes( + &capability.exact_term_kinds, + capability.exact_term_kinds.capacity(), + |_| 0, + )) + .saturating_add(vec_bytes( + &capability.supported_languages, + capability.supported_languages.capacity(), + |id| id.as_str().len(), + )) + .saturating_add(vec_bytes( + &capability.edge_authority_classes, + capability.edge_authority_classes.capacity(), + |_| 0, + )) + .saturating_add(vec_bytes( + &capability.sanitization_receipts, + capability.sanitization_receipts.capacity(), + |id| id.as_str().len(), + )) +} + +/// Allocations retained by the projection capability, including compact +/// restored outputs. Struct bodies are charged by the containing generation. +pub(super) fn projection_heap_bytes(projection: &ProjectionPublicationHandoffV1) -> usize { + let request = projection.request(); + let changes = &request.changes; + vec_bytes( + &changes.added_or_changed, + changes.added_or_changed.capacity(), + changed_chunk_heap_bytes, + ) + .saturating_add(vec_bytes( + &changes.deleted, + changes.deleted.capacity(), + changed_chunk_heap_bytes, + )) + .saturating_add(vec_bytes( + projection.output_overrides(), + projection.output_override_capacity(), + |(_, digest)| digest.as_str().len(), + )) + .saturating_add(request.request_digest.as_str().len()) + .saturating_add(projection_key_bytes(&request.target_projection_key)) + .saturating_add( + request + .previous_projection_key .as_ref() - .map(|digest| digest.as_str()))) + .map_or(0, projection_key_bytes), + ) + .saturating_add(opt(changes.from_generation.as_ref().map(|id| id.as_str()))) + .saturating_add(changes.to_generation.as_str().len()) + .saturating_add(changes.manifest_digest.as_str().len()) + .saturating_add(changes.reused_digest.as_str().len()) + .saturating_add(projection.publication_digest().as_str().len()) + .saturating_add(ARC_HEADER_BYTES) + .saturating_add(projection.shared_value_size()) } pub(crate) fn snapshot_file_heap_bytes(file: &SanitizedCodeFileV1) -> usize { @@ -306,6 +582,330 @@ pub(crate) fn snapshot_file_heap_bytes(file: &SanitizedCodeFileV1) -> usize { .saturating_add(file.content_digest.as_str().len()) } +/// Descriptor allocations that remain live after cold resolution drops its facts. +pub(super) fn file_segment_descriptors_bytes( + files: &[super::partitioned_codec::PartitionedFileSegmentDescriptorV1], + capacity: usize, +) -> usize { + vec_bytes(files, capacity, |file| { + file.file_occurrence_id + .as_str() + .len() + .saturating_add(file.segment_digest.as_str().len()) + .saturating_add(file.symbol_identities_digest.as_str().len()) + }) +} + +pub(super) fn staged_resolution_descriptors_bytes( + parts: &super::sparse_successor::StagedResolutionPartsV1, +) -> usize { + vec_bytes( + &parts.file_evidence, + parts.file_evidence.capacity(), + |file| { + file.file_occurrence_id + .as_str() + .len() + .saturating_add(file.segment_digest.as_str().len()) + }, + ) + .saturating_add(vec_bytes( + &parts.code_graph_pages, + parts.code_graph_pages.capacity(), + |page| { + page.file_occurrence_id + .as_str() + .len() + .saturating_add(page.logical_path.capacity()) + .saturating_add(page.page_digest.as_str().len()) + }, + )) + .saturating_add(resolution_index_descriptor_bytes(&parts.resolution_index)) +} + +fn resolution_index_descriptor_bytes( + index: &super::resolution_index::PartitionedResolutionIndexDescriptorV1, +) -> usize { + // Cold index sealing reserves each private page vector at its exact + // final page count. The import-alias singleton is inline. + let count = index.segments().count(); + count + .saturating_sub(1) + .saturating_mul(size_of::< + super::resolution_index::PartitionedIndexSegmentDescriptorV1, + >()) + .saturating_add( + index + .segments() + .map(|segment| segment.segment_digest.as_str().len()) + .sum::(), + ) +} + +pub(super) fn generation_evidence_descriptor_bytes( + evidence: &super::partitioned_codec::PartitionedGenerationEvidenceDescriptorV1, +) -> usize { + evidence + .segment_digest + .as_str() + .len() + .saturating_add(vec_bytes( + &evidence.pages, + evidence.pages.capacity(), + |page| page.page_digest.as_str().len(), + )) +} + +pub(super) fn snapshot_heap_bytes(snapshot: &tracedecay_domain::SanitizedCodeSnapshotV1) -> usize { + snapshot + .repository + .as_str() + .len() + .saturating_add(opt(snapshot.worktree.as_ref().map(|id| id.as_str()))) + .saturating_add(opt(snapshot.reference.as_ref().map(|id| id.as_str()))) + .saturating_add(opt(snapshot.source_revision.as_ref().map(|id| id.as_str()))) + .saturating_add(snapshot.sanitizer_revision.as_str().len()) + .saturating_add(snapshot.content_identity.as_str().len()) + .saturating_add(vec_bytes( + &snapshot.files, + snapshot.files.capacity(), + snapshot_file_heap_bytes, + )) + .saturating_add(vec_bytes( + &snapshot.sanitization_receipts, + snapshot.sanitization_receipts.capacity(), + |id| id.as_str().len(), + )) + .saturating_add(vec_bytes( + &snapshot.omitted_sources, + snapshot.omitted_sources.capacity(), + |source| { + source + .git_path + .capacity() + .saturating_add(match &source.reason { + tracedecay_domain::CodeSourceOmissionReasonV1::PrivacyWithheld { + detail, + } => detail.capacity(), + tracedecay_domain::CodeSourceOmissionReasonV1::UnrepresentablePath => 0, + }) + }, + )) +} + +fn manifest_heap_bytes(manifest: &tracedecay_domain::CodeGenerationManifestV1) -> usize { + let sources = manifest.source_commitments.as_ref().map_or(0, |sources| { + sources + .incremental_manifest_digest + .as_str() + .len() + .saturating_add(sources.full_replay_digest.as_str().len()) + .saturating_add(opt(sources + .parent_full_replay_digest + .as_ref() + .map(|id| id.as_str()))) + }); + manifest + .project_id + .as_str() + .len() + .saturating_add(manifest.generation_id.as_str().len()) + .saturating_add(manifest.snapshot_digest.as_str().len()) + .saturating_add(manifest.invalidation_digest.as_str().len()) + .saturating_add(manifest.registry_revision.as_str().len()) + .saturating_add(manifest.sanitizer_revision.as_str().len()) + .saturating_add(manifest.chunker_revision.as_str().len()) + .saturating_add(manifest.privacy_domain.as_str().len()) + .saturating_add(opt(manifest + .parent_generation + .as_ref() + .map(|id| id.as_str()))) + .saturating_add(manifest.seal.expected_digest.as_str().len()) + .saturating_add(manifest.seal.planner.as_str().len()) + .saturating_add(sources) + .saturating_add(vec_bytes( + &manifest.grammar_revisions, + manifest.grammar_revisions.capacity(), + |(language, revision)| { + language + .as_str() + .len() + .saturating_add(revision.as_str().len()) + }, + )) + .saturating_add(vec_bytes( + &manifest.extractor_revisions, + manifest.extractor_revisions.capacity(), + |(language, revision)| { + language + .as_str() + .len() + .saturating_add(revision.as_str().len()) + }, + )) +} + +/// Serde's sequence visitors can hold an old vector beside its doubled +/// replacement. Count inline slots separately from string/byte payloads: the +/// typed JSON length bounds those payloads and the unescape scratch buffer. +/// This is a pre-decode bound over this exact manifest, not a JSON multiplier +/// standing in for a decoded object tree. +pub(super) fn partitioned_decode_workspace_bytes( + generation: &super::partitioned_codec::PartitionedPublishedGenerationRefV1<'_>, + json_bytes: usize, + string_scratch: usize, +) -> usize { + fn slots(values: &[T]) -> usize { + // Vec's minimum nonzero allocation for these DTO elements is four. + if values.is_empty() { + 0 + } else { + values + .len() + .max(4) + .saturating_mul(3) + .saturating_mul(size_of::()) + } + } + let snapshot = generation.snapshot; + let capability = generation.capability; + let index_count = generation + .resolution_index + .segments() + .count() + .saturating_sub(1); + let inline = slots(&snapshot.files) + .saturating_add(slots(&snapshot.sanitization_receipts)) + .saturating_add(slots(&snapshot.omitted_sources)) + .saturating_add( + snapshot + .omitted_sources + .iter() + .map(|source| { + if source.git_path.is_empty() { + 0 + } else { + source.git_path.len().max(8).saturating_mul(3) + } + }) + .sum::(), + ) + .saturating_add(slots(&generation.manifest.grammar_revisions)) + .saturating_add(slots(&generation.manifest.extractor_revisions)) + .saturating_add(slots(generation.ignored_source_admissions)) + .saturating_add(slots(generation.file_segments)) + .saturating_add(slots(generation.file_evidence)) + .saturating_add(slots(generation.code_graph_pages)) + .saturating_add(slots(&generation.generation_evidence.pages)) + .saturating_add( + index_count + .max(8) + .saturating_mul(3) + .saturating_mul(size_of::< + super::resolution_index::PartitionedIndexSegmentDescriptorV1, + >()), + ) + .saturating_add(slots(&capability.language_descriptor_revisions)) + .saturating_add(slots(&capability.available_grains)) + .saturating_add(slots(&capability.exact_term_kinds)) + .saturating_add(slots(&capability.supported_languages)) + .saturating_add(slots(&capability.edge_authority_classes)) + .saturating_add(slots(&capability.sanitization_receipts)); + let present = generation.file_segments.len(); + let validation = present + .max(4) + .saturating_mul(3) + .saturating_mul(size_of::<(usize, &tracedecay_domain::FileOccurrenceId)>()) + // HashMap rehash can retain the preceding table beside its replacement. + .saturating_add( + hash_table_bytes::<(usize, &tracedecay_domain::FileOccurrenceId)>( + super::helpers::resolution_hash_capacity(present), + ) + .saturating_mul(2), + ) + .saturating_add(btree_bytes( + snapshot.files.len(), + size_of::<&tracedecay_domain::FileOccurrenceId>(), + )) + .saturating_add(btree_bytes(snapshot.files.len(), size_of::<&String>())); + let cloned_sources = generation + .ignored_source_admissions + .len() + .saturating_mul(size_of::()) + .saturating_add( + generation + .ignored_source_admissions + .iter() + .map(|source| source.logical_path.len()) + .sum::(), + ) + .saturating_add(opt(generation + .repository_parse_identity + .tree + .as_ref() + .map(|id| id.as_str()))) + .saturating_add(generation.ignored_source_admissions_digest.as_str().len()); + // All decoded scalar payloads, then one unescape buffer bounded by the + // largest known DTO row. Its geometric growth can overlap old/new buffers. + json_bytes + .saturating_add(string_scratch.saturating_mul(3)) + .saturating_add(inline) + .saturating_add(validation) + .saturating_add(cloned_sources) + .saturating_add(ARC_HEADER_BYTES) + .saturating_add(size_of::()) + .saturating_add(71) // The newly computed lane digest survives the parsed descriptors. +} + +pub(super) fn chunk_policy_heap_bytes(policy: &super::ChunkPolicyRevisionSummaryV1) -> usize { + match policy { + super::ChunkPolicyRevisionSummaryV1::Uniform(revision) => revision.as_str().len(), + super::ChunkPolicyRevisionSummaryV1::Empty | super::ChunkPolicyRevisionSummaryV1::Mixed => { + 0 + } + } +} + +/// All compact metadata is owned beside the sealed bytes and shared projection. +/// The policy summary owns one policy identity when the census is uniform. +pub(super) fn compact_metadata_bytes( + metadata: &super::VerifiedSealedTextGenerationMetadataV1, + policy_bytes: usize, +) -> usize { + ARC_HEADER_BYTES + .saturating_add(size_of::()) + .saturating_add(manifest_heap_bytes(metadata.manifest())) + .saturating_add(snapshot_heap_bytes(metadata.snapshot())) + .saturating_add(opt(metadata + .repository_parse_identity() + .tree + .as_ref() + .map(|id| id.as_str()))) + .saturating_add(metadata.ignored_source_admissions_digest().as_str().len()) + .saturating_add(vec_bytes( + metadata.ignored_source_admissions(), + metadata.ignored_source_admissions().len(), + |source| source.logical_path.capacity(), + )) + .saturating_add(policy_bytes) +} + +/// Retained allocations of a compact publication, including its shared +/// construction guard's own Arc allocation. Caller-owned inline bodies are +/// excluded; cloned publications share all three Arc allocations. +pub(super) fn sealed_publication_bytes( + publication: &super::CodeIndexSealedPublicationV1, + policy_bytes: usize, +) -> usize { + projection_heap_bytes(&publication.projection) + .saturating_add(publication.manifest_bytes.len()) + .saturating_add(ARC_HEADER_BYTES) + .saturating_add(publication.lane_digest.as_str().len()) + .saturating_add(compact_metadata_bytes(&publication.metadata, policy_bytes)) + .saturating_add(ARC_HEADER_BYTES) + .saturating_add(size_of::()) +} + /// A page's authority, extraction record, and document header: what every /// page keeps, reduced or not. pub(super) fn page_identity_bytes(file: &FileGenerationArtifactsV1) -> usize { @@ -436,8 +1036,6 @@ pub(super) fn file_bytes(file: &FileGenerationArtifactsV1) -> usize { impl CodeIndexPublishedGenerationV1 { pub(super) fn measure_resident_bytes(&self) -> usize { let snapshot = &self.snapshot; - let request = self.projection.request(); - let receipt = self.projection.receipt(); let chunk_records = self.chunks.chunks().iter().fold(0_usize, |bytes, chunk| { bytes.saturating_add(chunk_bytes(chunk)) }); @@ -454,21 +1052,7 @@ impl CodeIndexPublishedGenerationV1 { .saturating_add(self.symbols.symbols.capacity()) .saturating_add(self.files.capacity()), ); - let projection = vec_bytes( - &request.changes.added_or_changed, - request.changes.added_or_changed.capacity(), - changed_chunk_heap_bytes, - ) - .saturating_add(vec_bytes( - &request.changes.deleted, - request.changes.deleted.capacity(), - changed_chunk_heap_bytes, - )) - .saturating_add(vec_bytes( - &receipt.receipts, - receipt.receipts.capacity(), - receipt_heap_bytes, - )); + let projection = projection_heap_bytes(&self.projection); let snapshot_bytes = vec_bytes(&snapshot.files, snapshot.files.capacity(), |file| { snapshot_file_heap_bytes(file) }) diff --git a/crates/tracedecay-code-index/src/production/resolution_index.rs b/crates/tracedecay-code-index/src/production/resolution_index.rs index 00a12b9945..04e18c1650 100644 --- a/crates/tracedecay-code-index/src/production/resolution_index.rs +++ b/crates/tracedecay-code-index/src/production/resolution_index.rs @@ -15,11 +15,9 @@ //! write identical bytes for identical files. use std::collections::{BTreeMap, BTreeSet}; -use std::io::Write as _; +use std::mem::size_of; use std::sync::Arc; -use flate2::Compression; -use flate2::write::DeflateEncoder; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tracedecay_domain::ManifestDigest; @@ -28,7 +26,10 @@ use super::partitioned_codec::{ SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, inflate_index_segment, verify_index_segment, }; -use super::{CodeIndexProductionErrorV1, FileGenerationArtifactsV1, collect_bounded_ordered}; +use super::resident_bytes::btree_bytes; +use super::resolution_view::ResolutionMemoryChangeV1; +use super::resolution_view::{ResolutionFileFactsV1, ResolutionFileV1}; +use super::{CodeIndexProductionErrorV1, FileGenerationArtifactsV1}; use crate::lineage::LineageSymbolRecordV1; /// Symbols a cold seal plans per definition page. @@ -95,7 +96,8 @@ fn page_of(key: &str, pages: usize) -> usize { } /// Simple name to defining file to the file's symbols of that name. -type DefinitionPageV1 = BTreeMap>>; +pub(super) type DefinitionPageV1 = + BTreeMap>>>; /// Name segment to the files with a reference spelling it. type ReferencePageV1 = BTreeMap>; /// Every `(local, imported)` pair an import renames. @@ -118,26 +120,23 @@ struct FileRowsV1<'a> { } impl<'a> FileRowsV1<'a> { - fn of(file: &'a FileGenerationArtifactsV1) -> Self { + fn of(file: &'a dyn ResolutionFileFactsV1) -> Self { let mut symbols = file - .artifacts - .symbols + .symbols() .iter() .map(|symbol| (symbol.simple_name.as_str(), symbol.as_ref())) .collect::>(); symbols.sort_by(|left, right| left.1.occurrence.cmp(&right.1.occurrence)); Self { - path: file.authority.logical_path.as_str(), + path: file.logical_path(), symbols, segments: file - .artifacts - .unresolved_references + .unresolved_references() .iter() .flat_map(|reference| name_segments(&reference.reference_name)) .collect(), aliases: file - .artifacts - .imports + .imports() .iter() .filter_map(|binding| { let local = binding.local_name.as_deref()?; @@ -161,47 +160,28 @@ fn planned_pages(symbols: usize) -> usize { fn encode_segment( value: &T, ) -> Result<(PartitionedIndexSegmentDescriptorV1, Vec), CodeIndexProductionErrorV1> { - let failed = |error: &dyn std::fmt::Display| { - contract(format!("sealed resolution index encoding failed: {error}")) - }; - let canonical = serde_json::to_vec(value).map_err(|error| failed(&error))?; - let mut encoder = DeflateEncoder::new(Vec::new(), Compression::new(PAGE_COMPRESSION_LEVEL_V1)); - encoder - .write_all(&canonical) - .map_err(|error| failed(&error))?; - let bytes = encoder.finish().map_err(|error| failed(&error))?; - let length = |bytes: &[u8]| { - u64::try_from(bytes.len()) - .map_err(|_| failed(&"sealed resolution index length exceeds u64")) - }; - Ok(( - PartitionedIndexSegmentDescriptorV1 { - segment_digest: ManifestDigest::from_sha256_bytes(&Sha256::digest(&bytes)) - .map_err(|error| contract(error.to_string()))?, - segment_size_bytes: length(&bytes)?, - decoded_size_bytes: length(&canonical)?, - }, - bytes, - )) + encode_segment_charged(value, 0, &mut |_| Ok(())) } -/// Encode `pages` on the indexing pool and publish them in page order. -fn publish_pages( - pages: &[T], - publish: &mut impl FnMut( - SealedGenerationSegmentPublicationV1<'_>, - ) -> Result<(), CodeIndexProductionErrorV1>, -) -> Result, CodeIndexProductionErrorV1> { - let encoded = collect_bounded_ordered(pages, |page| encode_segment(page))?; - let mut descriptors = Vec::with_capacity(encoded.len()); - for (descriptor, bytes) in encoded { - publish(SealedGenerationSegmentPublicationV1::ResolutionIndex { - digest: &descriptor.segment_digest, - bytes: &bytes, - })?; - descriptors.push(descriptor); - } - Ok(descriptors) +fn encode_segment_charged( + value: &T, + base: usize, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<(PartitionedIndexSegmentDescriptorV1, Vec), CodeIndexProductionErrorV1> { + let (bytes, decoded_size_bytes) = super::partitioned_codec::encode_compressed_rows_charged( + value, + PAGE_COMPRESSION_LEVEL_V1, + base, + retain, + )?; + let descriptor = PartitionedIndexSegmentDescriptorV1 { + segment_digest: ManifestDigest::from_sha256_bytes(&Sha256::digest(&bytes)) + .map_err(|error| contract(error.to_string()))?, + segment_size_bytes: u64::try_from(bytes.len()) + .map_err(|_| contract("resolution index length exceeds u64"))?, + decoded_size_bytes, + }; + Ok((descriptor, bytes)) } type BorrowedDefinitionPageV1<'a> = @@ -214,44 +194,216 @@ type BorrowedReferencePageV1<'a> = BTreeMap<&'a str, BTreeSet<&'a str>>; level = "trace", skip_all )] -pub(super) fn seal_resolution_index( - files: &[Arc], +pub(super) fn seal_resolution_index( + files: &[T], + publish: impl FnMut( + SealedGenerationSegmentPublicationV1<'_>, + ) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result { + seal_resolution_index_charged(files, &mut |_| Ok(()), publish) +} + +/// Each canonical partition is planned, charged, encoded and released before +/// the next one. Only the final descriptors span partitions. +pub(super) fn seal_resolution_index_charged( + files: &[T], + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, mut publish: impl FnMut( SealedGenerationSegmentPublicationV1<'_>, ) -> Result<(), CodeIndexProductionErrorV1>, ) -> Result { - let rows = files - .iter() - .map(|file| FileRowsV1::of(file)) - .collect::>(); - let pages = planned_pages(rows.iter().map(|rows| rows.symbols.len()).sum()); - let mut definitions = vec![BorrowedDefinitionPageV1::new(); pages]; - let mut references = vec![BorrowedReferencePageV1::new(); pages]; + let mut symbols = 0_usize; + for file in files { + symbols = symbols.saturating_add(file.binding()?.symbols().len()); + } + let pages = planned_pages(symbols); + let descriptor_bytes = pages + .saturating_mul(2) + .saturating_add(1) + .saturating_mul(size_of::() + "sha256:".len() + 64); + let mut routing = descriptor_bytes.saturating_add(pages.saturating_mul( + size_of::>() + size_of::>(), + )); + retain(u64::try_from(routing).map_err(|_| contract("resolution index charge exceeds u64"))?)?; + let mut definition_rows = (0..pages) + .map(|_| Vec::new()) + .collect::>>(); + let mut reference_rows = (0..pages) + .map(|_| Vec::new()) + .collect::>>(); let mut aliases = BTreeSet::new(); - for file in &rows { - for (name, symbol) in &file.symbols { - definitions[page_of(name, pages)] - .entry(name) - .or_default() - .entry(file.path) - .or_default() - .push(symbol); + for file in files { + let file = file.binding()?; + let file_workspace = file + .symbols() + .len() + .saturating_mul(size_of::<&LineageSymbolRecordV1>()); + retain( + u64::try_from(routing.saturating_add(file_workspace)) + .map_err(|_| contract("resolution index charge exceeds u64"))?, + )?; + let mut selected = file + .symbols() + .iter() + .map(|symbol| symbol.as_ref()) + .collect::>(); + selected.sort_unstable_by(|left, right| left.occurrence.cmp(&right.occurrence)); + for symbol in selected { + let page = page_of(&symbol.simple_name, pages); + super::resolution_view::reserve_resolution_vec( + &mut definition_rows[page], + 1, + &mut |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut routing, |routing| { + retain( + u64::try_from(routing.saturating_add(file_workspace)) + .map_err(|_| contract("resolution index charge exceeds u64"))?, + ) + }) + }, + )?; + definition_rows[page].push((file.logical_path(), symbol)); + } + for segment in file + .unresolved_references() + .iter() + .flat_map(|reference| name_segments(&reference.reference_name)) + { + let page = page_of(segment, pages); + super::resolution_view::reserve_resolution_vec( + &mut reference_rows[page], + 1, + &mut |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut routing, |routing| { + retain( + u64::try_from(routing) + .map_err(|_| contract("resolution index charge exceeds u64"))?, + ) + }) + }, + )?; + reference_rows[page].push((segment, file.logical_path())); + } + for binding in file.imports() { + if let (Some(local), Some(imported)) = ( + binding.local_name.as_deref(), + binding.imported_name.as_deref(), + ) && local != imported + && !aliases.contains(&(local, imported)) + { + let before = btree_bytes(aliases.len(), size_of::<(&str, &str)>()); + let after = btree_bytes(aliases.len().saturating_add(1), size_of::<(&str, &str)>()); + routing = routing.saturating_add(after.saturating_sub(before)); + retain( + u64::try_from(routing) + .map_err(|_| contract("resolution index charge exceeds u64"))?, + )?; + aliases.insert((local, imported)); + } + } + } + let mut definitions = Vec::with_capacity(pages); + let mut references = Vec::with_capacity(pages); + for mut inputs in definition_rows { + // Group the borrowed rows before allocating the canonical page. Stable + // sorting preserves the existing per-file occurrence order. + let sort_bytes = inputs + .len() + .saturating_mul(size_of::<(&str, &LineageSymbolRecordV1)>()); + retain( + u64::try_from(routing.saturating_add(sort_bytes)) + .map_err(|_| contract("resolution index charge exceeds u64"))?, + )?; + inputs.sort_by(|left, right| { + left.1 + .simple_name + .cmp(&right.1.simple_name) + .then_with(|| left.0.cmp(right.0)) + }); + let names = inputs.chunk_by(|left, right| left.1.simple_name == right.1.simple_name); + let mut current = routing.saturating_add(btree_bytes( + names.clone().count(), + size_of::<(&str, BTreeMap<&str, Vec<&LineageSymbolRecordV1>>)>(), + )); + for name in names { + current = current.saturating_add(btree_bytes( + name.chunk_by(|left, right| left.0 == right.0).count(), + size_of::<(&str, Vec<&LineageSymbolRecordV1>)>(), + )); + current = current.saturating_add( + name.len() + .saturating_mul(size_of::<&LineageSymbolRecordV1>()), + ); } - for segment in &file.segments { - references[page_of(segment, pages)] - .entry(segment) - .or_default() - .insert(file.path); + // All tree bodies and exact symbol vectors coexist under one admission. + retain( + u64::try_from(current).map_err(|_| contract("resolution index charge exceeds u64"))?, + )?; + let mut rows = BorrowedDefinitionPageV1::new(); + for name in inputs.chunk_by(|left, right| left.1.simple_name == right.1.simple_name) { + let mut files = BTreeMap::new(); + for file in name.chunk_by(|left, right| left.0 == right.0) { + let mut symbols = Vec::with_capacity(file.len()); + for (_, symbol) in file { + symbols.push(*symbol); + } + files.insert(file[0].0, symbols); + } + rows.insert(name[0].1.simple_name.as_str(), files); } - aliases.extend(file.aliases.iter().copied()); + let (descriptor, bytes) = encode_segment_charged(&rows, current, retain)?; + publish(SealedGenerationSegmentPublicationV1::ResolutionIndex { + digest: &descriptor.segment_digest, + bytes: &bytes, + })?; + definitions.push(descriptor); + routing = routing.saturating_sub( + inputs + .capacity() + .saturating_mul(size_of::<(&str, &LineageSymbolRecordV1)>()), + ); } - let definitions = publish_pages(&definitions, &mut publish)?; - let references = publish_pages(&references, &mut publish)?; - let (import_aliases, bytes) = encode_segment(&aliases)?; + for mut inputs in reference_rows { + inputs.sort_unstable(); + inputs.dedup(); + let segments = inputs.chunk_by(|left, right| left.0 == right.0); + let mut current = routing.saturating_add(btree_bytes( + segments.clone().count(), + size_of::<(&str, BTreeSet<&str>)>(), + )); + for segment in segments { + current = current.saturating_add(btree_bytes(segment.len(), size_of::<&str>())); + } + retain( + u64::try_from(current).map_err(|_| contract("resolution index charge exceeds u64"))?, + )?; + let mut rows = BorrowedReferencePageV1::new(); + for segment in inputs.chunk_by(|left, right| left.0 == right.0) { + let mut paths = BTreeSet::new(); + for (_, path) in segment { + paths.insert(*path); + } + rows.insert(segment[0].0, paths); + } + let (descriptor, bytes) = encode_segment_charged(&rows, current, retain)?; + publish(SealedGenerationSegmentPublicationV1::ResolutionIndex { + digest: &descriptor.segment_digest, + bytes: &bytes, + })?; + references.push(descriptor); + routing = + routing.saturating_sub(inputs.capacity().saturating_mul(size_of::<(&str, &str)>())); + } + let base = routing; + let (import_aliases, bytes) = encode_segment_charged(&aliases, base, retain)?; publish(SealedGenerationSegmentPublicationV1::ResolutionIndex { digest: &import_aliases.segment_digest, bytes: &bytes, })?; + retain( + u64::try_from(descriptor_bytes) + .map_err(|_| contract("resolution index charge exceeds u64"))?, + )?; Ok(PartitionedResolutionIndexDescriptorV1 { definitions, references, @@ -309,6 +461,21 @@ impl<'r> ResolutionIndexReaderV1<'r> { .map_err(|error| contract(format!("sealed resolution index decoding failed: {error}"))) } + pub(super) fn definition_page_read_bytes( + &self, + page: usize, + ) -> Result { + let descriptor = self + .descriptor + .definitions + .get(page) + .ok_or_else(|| contract("sealed resolution index page is out of range"))?; + descriptor + .segment_size_bytes + .checked_add(descriptor.decoded_size_bytes) + .ok_or_else(|| contract("resolution page read size exceeds u64")) + } + pub(super) fn definition_page( &self, page: usize, @@ -358,11 +525,11 @@ pub(super) fn reseal_resolution_index( ) -> Result { let before = before .iter() - .map(|file| FileRowsV1::of(file)) + .map(|file| FileRowsV1::of(*file)) .collect::>(); let after = after .iter() - .map(|file| FileRowsV1::of(file)) + .map(|file| FileRowsV1::of(*file)) .collect::>(); fn aliases<'a>(rows: &[FileRowsV1<'a>]) -> BTreeSet<(&'a str, &'a str)> { rows.iter() @@ -387,7 +554,6 @@ pub(super) fn reseal_resolution_index( reference_pages.extend(file.segments.iter().map(|segment| page_of(segment, pages))); } let mut descriptor = parent.descriptor.clone(); - let mut rewritten = BTreeMap::new(); for page in definition_pages { let mut rows = parent.definition_page(page)?; rows.retain(|_, files| { @@ -401,20 +567,17 @@ pub(super) fn reseal_resolution_index( .or_default() .entry(file.path.to_owned()) .or_default() - .push((*symbol).clone()); + .push(Arc::new((*symbol).clone())); } } } - rewritten.insert(page, encode_segment(&rows)?); - } - for (page, (segment, bytes)) in rewritten { + let (segment, bytes) = encode_segment(&rows)?; publish(SealedGenerationSegmentPublicationV1::ResolutionIndex { digest: &segment.segment_digest, bytes: &bytes, })?; descriptor.definitions[page] = segment; } - let mut rewritten = BTreeMap::new(); for page in reference_pages { let mut rows = parent.reference_page(page)?; rows.retain(|_, files| { @@ -430,9 +593,7 @@ pub(super) fn reseal_resolution_index( } } } - rewritten.insert(page, encode_segment(&rows)?); - } - for (page, (segment, bytes)) in rewritten { + let (segment, bytes) = encode_segment(&rows)?; publish(SealedGenerationSegmentPublicationV1::ResolutionIndex { digest: &segment.segment_digest, bytes: &bytes, diff --git a/crates/tracedecay-code-index/src/production/resolution_outputs.rs b/crates/tracedecay-code-index/src/production/resolution_outputs.rs index f93cd4d8c4..496f9139ff 100644 --- a/crates/tracedecay-code-index/src/production/resolution_outputs.rs +++ b/crates/tracedecay-code-index/src/production/resolution_outputs.rs @@ -1,33 +1,147 @@ //! Canonical graph resolution outputs derived while a generation seals. -use std::sync::Arc; - -use tracedecay_domain::CanonicalRelationEdgeV1; -use tracedecay_graph_db::GraphDbError; +use std::collections::HashMap; +use tracedecay_domain::{CanonicalRelationEdgeV1, SourceSpan, SymbolOccurrenceId}; use crate::chunks::CodeIndexUnresolvedReferenceV1; use crate::graph_projection::unresolved_call_limitations; -use super::helpers::unresolved_import_calls; -use super::resolution_view::FileSymbolsByNameV1; -use super::{CodeIndexProductionErrorV1, FileGenerationArtifactsV1}; +use super::CodeIndexProductionErrorV1; +use super::resolution_view::ResolutionMemoryChangeV1; +use super::resolution_view::{FileSymbolsByNameV1, ResolutionFileV1}; -pub(super) fn unresolved_calls_for_edges( - files: &[Arc], +/// Caller gaps are derived per file, so their borrowed site tables and owned +/// projection rows cannot grow with the whole corpus at once. +pub(super) fn unresolved_calls_for_edges_charged( + files: &[T], edges: &[CanonicalRelationEdgeV1], - check: &dyn Fn() -> Result<(), GraphDbError>, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, ) -> Result, CodeIndexProductionErrorV1> { - check().map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - let import_unresolved = unresolved_import_calls(files, &FileSymbolsByNameV1::new(files), None); - let references = files - .iter() - .flat_map(|file| { - file.artifacts - .unresolved_references - .iter() - .map(|reference| (file.authority.logical_path.as_str(), reference)) + let mut index_bytes = 0_usize; + let mut charge = |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut index_bytes, |index_bytes| { + retain(u64::try_from(index_bytes).map_err(|_| { + CodeIndexProductionErrorV1::Contract("call index charge exceeds u64".to_owned()) + })?) }) - .collect::>(); - unresolved_call_limitations(&references, edges.iter(), import_unresolved, check) - .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string())) + }; + let by_name = FileSymbolsByNameV1::new_charged(files, &mut charge)?; + let mut modules = super::helpers::ResolutionModulesV1::new_charged(files, &mut charge)?; + let mut owners = HashMap::new(); + for (index, file) in files.iter().enumerate() { + for symbol in file.binding()?.symbols() { + super::resolution_view::reserve_resolution_map(&mut owners, &mut charge)?; + owners.insert(&symbol.occurrence, index); + } + } + charge(ResolutionMemoryChangeV1::Retain( + files + .len() + .saturating_mul(std::mem::size_of::>()), + ))?; + let mut routed = (0..files.len()).map(|_| Vec::new()).collect::>(); + for edge in edges { + if let Some(index) = owners.get(&edge.from_occurrence) { + super::resolution_view::reserve_resolution_vec(&mut routed[*index], 1, &mut charge)?; + routed[*index].push(edge); + } + } + let mut calls = Vec::new(); + for (index, file) in files.iter().enumerate() { + let facts = file.binding()?; + let base = index_bytes.saturating_add(super::resident_bytes::vec_bytes( + &calls, + calls.capacity(), + super::resident_bytes::unresolved_heap_bytes, + )); + let selected_bytes = facts.unresolved_references().len().saturating_mul( + std::mem::size_of::<(usize, &CodeIndexUnresolvedReferenceV1)>() + + std::mem::size_of::<(&str, &CodeIndexUnresolvedReferenceV1)>() + + std::mem::size_of::(), + ); + let mut current = base.saturating_add(selected_bytes); + retain(u64::try_from(current).map_err(|_| { + CodeIndexProductionErrorV1::Contract("call workspace exceeds u64".to_owned()) + })?)?; + let selection = vec![( + index, + (0..facts.unresolved_references().len()).collect::>(), + )]; + let imports = super::helpers::unresolved_import_calls_with_modules( + files, + &by_name, + &modules, + Some(&selection), + &mut |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut current, |current| { + retain(u64::try_from(current).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "call workspace exceeds u64".to_owned(), + ) + })?) + }) + }, + )?; + let references = facts + .unresolved_references() + .iter() + .map(|reference| (file.logical_path(), reference)) + .collect::>(); + // At most every source reference survives as a call limitation. The + // existing projector may append import gaps, already owned above. + current = current + .saturating_add(super::resident_bytes::vec_bytes( + facts.unresolved_references(), + facts.unresolved_references().len(), + super::resident_bytes::unresolved_heap_bytes, + )) + .saturating_add(super::resident_bytes::btree_bytes( + facts.unresolved_references().len(), + std::mem::size_of::<((&SymbolOccurrenceId, SourceSpan), Option<&str>)>(), + )) + .saturating_add(super::resident_bytes::btree_bytes( + routed[index].len(), + std::mem::size_of::<((&SymbolOccurrenceId, SourceSpan), &str)>(), + )) + .saturating_add(super::resident_bytes::btree_bytes( + routed[index].len(), + std::mem::size_of::<(&SymbolOccurrenceId, SourceSpan)>(), + )); + retain(u64::try_from(current).map_err(|_| { + CodeIndexProductionErrorV1::Contract("call workspace exceeds u64".to_owned()) + })?)?; + let result = unresolved_call_limitations( + &references, + routed[index].iter().copied(), + imports, + &|| Ok(()), + ) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; + super::resolution_view::reserve_resolution_vec( + &mut calls, + result.len(), + &mut |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut current, |current| { + retain(u64::try_from(current).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "call workspace exceeds u64".to_owned(), + ) + })?) + }) + }, + )?; + calls.extend(result); + modules.release_file_memo(index); + } + drop(modules); + drop(by_name); + retain( + u64::try_from(super::resident_bytes::vec_bytes( + &calls, + calls.capacity(), + super::resident_bytes::unresolved_heap_bytes, + )) + .map_err(|_| CodeIndexProductionErrorV1::Contract("call outputs exceed u64".to_owned()))?, + )?; + Ok(calls) } diff --git a/crates/tracedecay-code-index/src/production/resolution_view.rs b/crates/tracedecay-code-index/src/production/resolution_view.rs index 3331818a8d..656eb554a2 100644 --- a/crates/tracedecay-code-index/src/production/resolution_view.rs +++ b/crates/tracedecay-code-index/src/production/resolution_view.rs @@ -8,25 +8,538 @@ //! while decoding only the files a lookup actually walks into. use std::collections::HashMap; +use std::hash::Hash; +use std::mem::size_of; use std::sync::Arc; -use super::FileGenerationArtifactsV1; +use super::resident_bytes::{ + abstention_heap_bytes, edge_heap_bytes, go_method_set_heap_bytes, import_heap_bytes, + symbol_bytes, unresolved_heap_bytes, vec_bytes, +}; + +use super::{CodeIndexProductionErrorV1, FileGenerationArtifactsV1}; +use crate::chunks::{ + CodeIndexCallableArityV1, CodeIndexEdgeAbstentionV1, CodeIndexGoMethodSetRowV1, + CodeIndexImportEvidenceV1, CodeIndexUnresolvedReferenceV1, +}; use crate::lineage::LineageSymbolRecordV1; +use tracedecay_domain::CanonicalRelationEdgeV1; + +/// Changes in live representation bytes; the caller's construction guard is +/// the admission authority. Releases happen only after the old allocation dies. +#[derive(Clone, Copy, Debug)] +pub(super) enum ResolutionMemoryChangeV1 { + Retain(usize), + Release(usize), +} + +impl ResolutionMemoryChangeV1 { + pub(super) fn apply_to(self, live: usize) -> Result { + match self { + Self::Retain(bytes) => live.checked_add(bytes), + Self::Release(bytes) => live.checked_sub(bytes), + } + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "resolution live-byte adjustment overflowed or underflowed".to_owned(), + ) + }) + } + + pub(super) fn apply_with( + self, + live: &mut usize, + retain: impl FnOnce(usize) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result<(), CodeIndexProductionErrorV1> { + let next = self.apply_to(*live)?; + retain(next)?; + *live = next; + Ok(()) + } + + pub(super) fn apply_to_u64(self, live: u64) -> Result { + let convert = |bytes| { + u64::try_from(bytes).map_err(|_| { + CodeIndexProductionErrorV1::Contract("resolution change exceeds u64".to_owned()) + }) + }; + match self { + Self::Retain(bytes) => live.checked_add(convert(bytes)?), + Self::Release(bytes) => live.checked_sub(convert(bytes)?), + } + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "resolution live-byte adjustment overflowed or underflowed".to_owned(), + ) + }) + } +} + +fn allocation_error(error: std::collections::TryReserveError) -> CodeIndexProductionErrorV1 { + CodeIndexProductionErrorV1::Contract(format!("resolution allocation failed: {error}")) +} + +/// The pinned Global allocator's exact Vec capacity and SwissTable allocation +/// class are admitted in full beside the still-live old body. Settlement can +/// only release bytes; it cannot admit an allocation after it happened. +fn settle_replacement( + before: usize, + replacement: usize, + actual: usize, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<(), CodeIndexProductionErrorV1> { + let unused = replacement.checked_sub(actual).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "resolution allocation exceeded its admitted capacity class".to_owned(), + ) + })?; + let release = before.checked_add(unused).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution release exceeds usize".to_owned()) + })?; + charge(ResolutionMemoryChangeV1::Release(release)) +} + +pub(super) fn reserve_resolution_vec( + values: &mut Vec, + additional: usize, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<(), CodeIndexProductionErrorV1> { + let needed = values.len().checked_add(additional).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution vector length overflowed".to_owned()) + })?; + if needed <= values.capacity() { + return Ok(()); + } + let capacity = needed + .max(values.capacity().checked_mul(2).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution vector capacity overflowed".to_owned()) + })?) + .max(4); + let before = values + .capacity() + .checked_mul(size_of::()) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution vector bytes overflowed".to_owned()) + })?; + let replacement = capacity.checked_mul(size_of::()).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution vector bytes overflowed".to_owned()) + })?; + let _layout = std::alloc::Layout::array::(capacity).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "resolution vector layout is invalid: {error}" + )) + })?; + charge(ResolutionMemoryChangeV1::Retain(replacement))?; + if let Err(error) = values.try_reserve_exact(capacity - values.len()) { + charge(ResolutionMemoryChangeV1::Release(replacement))?; + return Err(allocation_error(error)); + } + let actual = values + .capacity() + .checked_mul(size_of::()) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution vector bytes overflowed".to_owned()) + })?; + settle_replacement(before, replacement, actual, charge) +} + +pub(super) fn reserve_resolution_map( + values: &mut HashMap, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<(), CodeIndexProductionErrorV1> { + if values.len() < values.capacity() { + return Ok(()); + } + let requested = values.capacity().checked_add(1).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution map capacity overflowed".to_owned()) + })?; + let capacity = super::resident_bytes::hash_table_capacity::<(K, V)>(requested)?; + let before = super::resident_bytes::hash_table_bytes::<(K, V)>(values.capacity()); + let replacement = super::resident_bytes::hash_table_bytes::<(K, V)>(capacity); + charge(ResolutionMemoryChangeV1::Retain(replacement))?; + if let Err(error) = values.try_reserve(capacity - values.len()) { + charge(ResolutionMemoryChangeV1::Release(replacement))?; + return Err(allocation_error(error)); + } + let actual = super::resident_bytes::hash_table_bytes::<(K, V)>(values.capacity()); + settle_replacement(before, replacement, actual, charge) +} + +pub(super) fn reserve_resolution_set( + values: &mut std::collections::HashSet, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<(), CodeIndexProductionErrorV1> { + if values.len() < values.capacity() { + return Ok(()); + } + let requested = values.capacity().checked_add(1).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution set capacity overflowed".to_owned()) + })?; + let capacity = super::resident_bytes::hash_table_capacity::(requested)?; + let before = super::resident_bytes::hash_table_bytes::(values.capacity()); + let replacement = super::resident_bytes::hash_table_bytes::(capacity); + charge(ResolutionMemoryChangeV1::Retain(replacement))?; + if let Err(error) = values.try_reserve(capacity - values.len()) { + charge(ResolutionMemoryChangeV1::Release(replacement))?; + return Err(allocation_error(error)); + } + let actual = super::resident_bytes::hash_table_bytes::(values.capacity()); + settle_replacement(before, replacement, actual, charge) +} + +/// The binding facts a resolution pass may retain after dropping a file page. +/// No source text, chunk, exact-authority, or clone payload is retained here. +#[derive(Clone)] +pub(super) struct CompactFileResolutionV1 { + logical_path: String, + language: String, + symbols: Vec>, + imports: Vec, + unresolved_references: Vec, + callable_arities: Vec, + go_method_sets: Vec, + edges: Vec, + edge_abstentions: Vec, + graph_owners: Vec, +} + +/// Compose the decoded file's existing canonical allocation authorities. No +/// source estimate is inferred from encoded bytes or corpus cardinality. +pub(super) fn resolution_source_bytes( + file: &FileGenerationArtifactsV1, +) -> Result { + let mut bytes = super::resident_bytes::file_bytes(file); + for chunk in &file.artifacts.chunks.chunks { + bytes = bytes + .checked_add(super::resident_bytes::chunk_bytes(chunk)) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "resolution source bytes overflowed".to_owned(), + ) + })?; + } + for symbol in &file.artifacts.symbols { + bytes = bytes.checked_add(symbol_bytes(symbol)).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution source bytes overflowed".to_owned()) + })?; + } + Ok(bytes) +} + +/// One traversal describes both retained vectors and cloned outer vectors. +/// Heap helpers remain the canonical capacity bounds; DTO cloning can only +/// reduce their nested String/Vec capacities to the source lengths. +fn resolution_rows_bytes(file: &dyn ResolutionFileFactsV1, cloned: bool) -> usize { + fn capacity(rows: &Vec, cloned: bool) -> usize { + if cloned { rows.len() } else { rows.capacity() } + } + vec_bytes(file.symbols(), capacity(file.symbols(), cloned), |_| 0) + .saturating_add(vec_bytes( + file.imports(), + capacity(file.imports(), cloned), + import_heap_bytes, + )) + .saturating_add(vec_bytes( + file.unresolved_references(), + capacity(file.unresolved_references(), cloned), + unresolved_heap_bytes, + )) + .saturating_add(vec_bytes( + file.callable_arities(), + capacity(file.callable_arities(), cloned), + |row| row.occurrence.as_str().len(), + )) + .saturating_add(vec_bytes( + file.go_method_sets(), + capacity(file.go_method_sets(), cloned), + go_method_set_heap_bytes, + )) + .saturating_add(vec_bytes( + file.edges(), + capacity(file.edges(), cloned), + edge_heap_bytes, + )) + .saturating_add(vec_bytes( + file.edge_abstentions(), + capacity(file.edge_abstentions(), cloned), + abstention_heap_bytes, + )) +} + +impl CompactFileResolutionV1 { + /// Admit every copied allocation and graph-owner workspace from borrowed + /// artifacts before either the borrowed set or the first clone is built. + /// The caller keeps this bound until its source owner has also dropped. + pub(super) fn from_file( + file: &FileGenerationArtifactsV1, + retain: &mut dyn FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result { + let owners = file + .artifacts + .chunks + .chunks + .iter() + .filter_map(|chunk| chunk.anchor.symbol_occurrence_id.as_ref()) + .chain( + file.artifacts + .symbols + .iter() + .map(|symbol| &symbol.occurrence), + ); + let mut owner_count = 0_usize; + let mut owner_heaps = 0_usize; + for owner in owners { + owner_count = owner_count.checked_add(1).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution owner count overflowed".to_owned()) + })?; + owner_heaps = owner_heaps + .checked_add(owner.as_str().len()) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "resolution owner bytes overflowed".to_owned(), + ) + })?; + } + let graph_vec = owner_count + .checked_mul(size_of::()) + .and_then(|bytes| bytes.checked_add(owner_heaps)) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution owner bytes overflowed".to_owned()) + })?; + let copy_bytes = size_of::() + .checked_add(file.authority.logical_path.len()) + .and_then(|bytes| bytes.checked_add(file.extraction.language.as_str().len())) + .and_then(|bytes| bytes.checked_add(resolution_rows_bytes(file, true))) + .and_then(|bytes| bytes.checked_add(graph_vec)) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution clone bytes overflowed".to_owned()) + })?; + let workspace = super::resident_bytes::btree_bytes( + owner_count, + size_of::<&tracedecay_domain::SymbolOccurrenceId>(), + ); + let bound = copy_bytes.checked_add(workspace).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract("resolution clone workspace overflowed".to_owned()) + })?; + retain(u64::try_from(bound).map_err(|_| { + CodeIndexProductionErrorV1::Contract("resolution clone bytes exceed u64".to_owned()) + })?)?; + let owners = super::graph_pages::owned_occurrences(file); + let mut graph_owners = Vec::with_capacity(owners.len()); + graph_owners.extend(owners.into_iter().cloned()); + let facts = Self { + logical_path: file.authority.logical_path.clone(), + language: file.extraction.language.as_str().to_owned(), + symbols: file.artifacts.symbols.clone(), + imports: file.artifacts.imports.clone(), + unresolved_references: file.artifacts.unresolved_references.clone(), + callable_arities: file.artifacts.callable_arities.clone(), + go_method_sets: file.artifacts.go_method_sets.clone(), + edges: file.artifacts.edges.clone(), + edge_abstentions: file.artifacts.edge_abstentions.clone(), + graph_owners, + }; + if facts + .retained_bytes() + .saturating_sub(facts.owned_symbol_bytes()) + > copy_bytes + { + return Err(CodeIndexProductionErrorV1::Contract( + "resolution clone exceeded its admitted capacity bound".to_owned(), + )); + } + Ok(facts) + } + pub(super) fn take_edges(&mut self) -> Vec { + std::mem::take(&mut self.edges) + } + + pub(super) fn graph_owners(&self) -> &[tracedecay_domain::SymbolOccurrenceId] { + &self.graph_owners + } + + /// The graph phase needs only occurrence ownership after resolution is sealed. + pub(super) fn into_graph_owners(self) -> Vec { + self.graph_owners + } + + pub(super) fn owned_symbol_bytes(&self) -> usize { + self.symbols.iter().map(|symbol| symbol_bytes(symbol)).sum() + } + + pub(super) fn retained_row_bytes(&self) -> usize { + resolution_rows_bytes(self, false) + } + + pub(super) fn graph_owner_bytes(&self) -> usize { + vec_bytes(&self.graph_owners, self.graph_owners.capacity(), |id| { + id.as_str().len() + }) + } + + pub(super) fn retained_bytes(&self) -> usize { + size_of::() + .saturating_add(self.logical_path.capacity()) + .saturating_add(self.language.capacity()) + .saturating_add(self.retained_row_bytes()) + .saturating_add(self.owned_symbol_bytes()) + .saturating_add(self.graph_owner_bytes()) + } +} + +/// Only stable binding facts are accessible through this boundary. File I/O +/// completes fallibly before a compact fact set enters a resolution pass. +pub(crate) trait ResolutionFileFactsV1: Send + Sync { + fn logical_path(&self) -> &str; + fn language(&self) -> &str; + fn symbols(&self) -> &Vec>; + fn imports(&self) -> &Vec; + fn unresolved_references(&self) -> &Vec; + fn callable_arities(&self) -> &Vec; + fn go_method_sets(&self) -> &Vec; + fn edges(&self) -> &Vec; + fn edge_abstentions(&self) -> &Vec; +} + +impl ResolutionFileFactsV1 for FileGenerationArtifactsV1 { + fn logical_path(&self) -> &str { + &self.authority.logical_path + } + fn language(&self) -> &str { + self.extraction.language.as_str() + } + fn symbols(&self) -> &Vec> { + &self.artifacts.symbols + } + fn imports(&self) -> &Vec { + &self.artifacts.imports + } + fn unresolved_references(&self) -> &Vec { + &self.artifacts.unresolved_references + } + fn callable_arities(&self) -> &Vec { + &self.artifacts.callable_arities + } + fn go_method_sets(&self) -> &Vec { + &self.artifacts.go_method_sets + } + fn edges(&self) -> &Vec { + &self.artifacts.edges + } + fn edge_abstentions(&self) -> &Vec { + &self.artifacts.edge_abstentions + } +} + +impl ResolutionFileFactsV1 for Arc { + fn logical_path(&self) -> &str { + ResolutionFileFactsV1::logical_path(self.as_ref()) + } + fn language(&self) -> &str { + ResolutionFileFactsV1::language(self.as_ref()) + } + fn symbols(&self) -> &Vec> { + ResolutionFileFactsV1::symbols(self.as_ref()) + } + fn imports(&self) -> &Vec { + ResolutionFileFactsV1::imports(self.as_ref()) + } + fn unresolved_references(&self) -> &Vec { + ResolutionFileFactsV1::unresolved_references(self.as_ref()) + } + fn callable_arities(&self) -> &Vec { + ResolutionFileFactsV1::callable_arities(self.as_ref()) + } + fn go_method_sets(&self) -> &Vec { + ResolutionFileFactsV1::go_method_sets(self.as_ref()) + } + fn edges(&self) -> &Vec { + ResolutionFileFactsV1::edges(self.as_ref()) + } + fn edge_abstentions(&self) -> &Vec { + ResolutionFileFactsV1::edge_abstentions(self.as_ref()) + } +} -/// One file of the set resolution runs over. -pub(crate) trait ResolutionFileV1: AsRef + Sync { +impl ResolutionFileFactsV1 for CompactFileResolutionV1 { fn logical_path(&self) -> &str { - &self.as_ref().authority.logical_path + &self.logical_path + } + fn language(&self) -> &str { + &self.language + } + fn symbols(&self) -> &Vec> { + &self.symbols + } + fn imports(&self) -> &Vec { + &self.imports + } + fn unresolved_references(&self) -> &Vec { + &self.unresolved_references + } + fn callable_arities(&self) -> &Vec { + &self.callable_arities + } + fn go_method_sets(&self) -> &Vec { + &self.go_method_sets } + fn edges(&self) -> &Vec { + &self.edges + } + fn edge_abstentions(&self) -> &Vec { + &self.edge_abstentions + } +} +impl ResolutionFileFactsV1 for &T { + fn logical_path(&self) -> &str { + ResolutionFileFactsV1::logical_path(*self) + } fn language(&self) -> &str { - self.as_ref().extraction.language.as_str() + ResolutionFileFactsV1::language(*self) + } + fn symbols(&self) -> &Vec> { + ResolutionFileFactsV1::symbols(*self) + } + fn imports(&self) -> &Vec { + ResolutionFileFactsV1::imports(*self) + } + fn unresolved_references(&self) -> &Vec { + ResolutionFileFactsV1::unresolved_references(*self) + } + fn callable_arities(&self) -> &Vec { + ResolutionFileFactsV1::callable_arities(*self) + } + fn go_method_sets(&self) -> &Vec { + ResolutionFileFactsV1::go_method_sets(*self) + } + fn edges(&self) -> &Vec { + ResolutionFileFactsV1::edges(*self) + } + fn edge_abstentions(&self) -> &Vec { + ResolutionFileFactsV1::edge_abstentions(*self) } } -impl ResolutionFileV1 for Arc {} +/// A file source can fail when its binding facts first load. The returned +/// facts belong to this one resolution operation, never a decoded file cache. +pub(crate) trait ResolutionFileV1: Sync { + fn logical_path(&self) -> &str; + fn language(&self) -> &str; + fn binding(&self) -> Result<&dyn ResolutionFileFactsV1, CodeIndexProductionErrorV1>; +} -impl ResolutionFileV1 for FileGenerationArtifactsV1 {} +impl ResolutionFileV1 for T { + fn logical_path(&self) -> &str { + ResolutionFileFactsV1::logical_path(self) + } + fn language(&self) -> &str { + ResolutionFileFactsV1::language(self) + } + fn binding(&self) -> Result<&dyn ResolutionFileFactsV1, CodeIndexProductionErrorV1> { + Ok(self) + } +} /// A symbol a simple name finds: its file's index in the set and its record. pub(crate) type NamedSymbolV1 = (usize, Arc); @@ -34,31 +547,595 @@ pub(crate) type NamedSymbolV1 = (usize, Arc); /// Every symbol of a file set by simple name, each name's symbols in file /// order and then in their file's symbol order. pub(crate) trait SymbolsByNameV1: Sync { - fn get(&self, name: &str) -> Option<&[NamedSymbolV1]>; + fn get(&self, name: &str) -> Result>, CodeIndexProductionErrorV1>; } /// [`SymbolsByNameV1`] over files held in memory. pub(crate) struct FileSymbolsByNameV1<'f> { - by_name: HashMap<&'f str, Vec>, + by_name: Vec<(usize, usize, &'f Arc)>, } impl<'f> FileSymbolsByNameV1<'f> { - pub(crate) fn new(files: &'f [T]) -> Self { - let mut by_name: HashMap<&str, Vec> = HashMap::new(); + pub(crate) fn new( + files: &'f [T], + ) -> Result { + Self::new_charged(files, &mut |_| Ok(())) + } + + pub(super) fn new_charged( + files: &'f [T], + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result { + let mut count = 0_usize; + for file in files { + count = count + .checked_add(file.binding()?.symbols().len()) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "symbol lookup length overflowed".to_owned(), + ) + })?; + } + let mut by_name = Vec::new(); + reserve_resolution_vec(&mut by_name, count, charge)?; for (index, file) in files.iter().enumerate() { - for symbol in &file.as_ref().artifacts.symbols { - by_name - .entry(symbol.simple_name.as_str()) - .or_default() - .push((index, Arc::clone(symbol))); + for (position, symbol) in file.binding()?.symbols().iter().enumerate() { + by_name.push((index, position, symbol)); } } - Self { by_name } + // Explicit input ordinals preserve each name's file/symbol order + // without stable-sort scratch or a separate allocation per name. + by_name.sort_unstable_by(|left, right| { + left.2 + .simple_name + .cmp(&right.2.simple_name) + .then_with(|| left.0.cmp(&right.0)) + .then_with(|| left.1.cmp(&right.1)) + }); + Ok(Self { by_name }) } } impl SymbolsByNameV1 for FileSymbolsByNameV1<'_> { - fn get(&self, name: &str) -> Option<&[NamedSymbolV1]> { - self.by_name.get(name).map(Vec::as_slice) + fn get(&self, name: &str) -> Result>, CodeIndexProductionErrorV1> { + let start = self + .by_name + .partition_point(|row| row.2.simple_name.as_str() < name); + let matching = &self.by_name[start..]; + let end = matching.partition_point(|row| row.2.simple_name.as_str() == name); + Ok((end != 0).then(|| { + matching[..end] + .iter() + .map(|row| (row.0, Arc::clone(row.2))) + .collect() + })) + } +} + +#[cfg(test)] +mod allocation_tests { + use std::collections::{HashMap, HashSet}; + use std::mem::size_of; + use std::num::NonZeroU64; + use std::sync::Arc; + + use tracedecay_code_extraction::ImportReexportScopeV1; + + use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, ProcessResidentSampleV1, ResidentMemoryComponentIdV1, + ResidentMemoryPressureV1, + }; + + use super::{ + CompactFileResolutionV1, FileSymbolsByNameV1, ResolutionFileV1, ResolutionMemoryChangeV1, + SymbolsByNameV1, reserve_resolution_map, reserve_resolution_set, reserve_resolution_vec, + resolution_source_bytes, + }; + use crate::production::resident_bytes::{hash_table_bytes, hash_table_capacity}; + use crate::production::{CodeIndexProductionErrorV1, ConstructionMemoryV1}; + + use crate::production::sealed_parent::{ + SealedParentGenerationV1, SparseFileSourceV1, SparseFileV1, + }; + use crate::production::sparse_increment_tests::{owner, request, scope}; + use crate::production::sparse_successor::resolve_staged_generation; + use crate::production::{ + CodeIndexPublishedBuildV1, MemorySealedPublicationStoreV1, SealedSegmentReaderV1, + UninterruptibleCodeIndexControlV1, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn published_fixture() -> (MemorySealedPublicationStoreV1, CodeIndexPublishedBuildV1) { + let store = MemorySealedPublicationStoreV1::default(); + let mut owner = owner(&store); + let request = request( + &[( + "src/lib.rs", + "rust", + "pub fn alpha() {}\npub fn beta() { alpha(); }\n", + )], + 1_000_000, + ); + let published = owner + .build_and_publish(request, &UninterruptibleCodeIndexControlV1) + .unwrap(); + (store, published) + } + + #[test] + fn symbol_lookup_preserves_duplicate_order_and_admits_before_retaining_rows() { + let store = MemorySealedPublicationStoreV1::default(); + let mut owner = owner(&store); + let published = owner + .build_and_publish( + request( + &[ + ( + "src/a.rs", + "rust", + "mod z { pub fn shared() {} } mod a { pub fn shared() {} }", + ), + ("src/b.rs", "rust", "pub fn shared() {} pub fn other() {}"), + ], + 1_000_000, + ), + &UninterruptibleCodeIndexControlV1, + ) + .unwrap(); + let decoded = crate::production::worker_tests::decoded_generation(&published); + let authority = memory(1); + let mut guard = ConstructionMemoryV1::new(authority.begin_operation()).unwrap(); + assert!( + FileSymbolsByNameV1::new_charged(&decoded.files, &mut |change| { + guard.resize(change.apply_to_u64(guard.charged_bytes)?) + }) + .is_err() + ); + assert_eq!(authority.snapshot().used_bytes, 0); + + let authority = memory(1024 * 1024); + let mut guard = ConstructionMemoryV1::new(authority.begin_operation()).unwrap(); + let lookup = FileSymbolsByNameV1::new_charged(&decoded.files, &mut |change| { + guard.resize(change.apply_to_u64(guard.charged_bytes)?) + }) + .unwrap(); + let expected = decoded + .files + .iter() + .enumerate() + .flat_map(|(index, file)| { + file.artifacts + .symbols + .iter() + .filter(|symbol| symbol.simple_name == "shared") + .map(move |symbol| (index, symbol)) + }) + .collect::>(); + assert_eq!(expected.len(), 3); + let actual = lookup.get("shared").unwrap().unwrap(); + assert_eq!(actual.len(), expected.len()); + for ((actual_index, actual_symbol), (expected_index, expected_symbol)) in + actual.iter().zip(expected) + { + assert_eq!(*actual_index, expected_index); + assert!(Arc::ptr_eq(actual_symbol, expected_symbol)); + } + assert!(lookup.get("absent").unwrap().is_none()); + drop(lookup); + drop(guard); + assert_eq!(authority.snapshot().used_bytes, 0); + } + + fn memory(limit: u64) -> Arc { + let limit = NonZeroU64::new(limit).unwrap(); + Arc::new(ProcessResidentMemoryV1::with_pressure( + limit, + Arc::new(ResidentMemoryPressureV1::with_sampler( + limit, + Arc::new(|| { + Some(ProcessResidentSampleV1 { + resident_bytes: 0, + unreclaimable_bytes: 0, + swapped_bytes: 0, + cgroup_headroom_bytes: None, + }) + }), + )), + )) + } + + #[test] + fn vector_replacement_needs_old_and_new_bodies_then_settles_to_actual_capacity() { + let mut values = vec![1_usize, 2, 3, 4]; + let old_capacity = values.capacity(); + let old = old_capacity * size_of::(); + let replacement = old_capacity * 2 * size_of::(); + let authority = memory((old + replacement) as u64); + let mut guard = ConstructionMemoryV1::new(authority.begin_operation()).unwrap(); + guard.resize(old as u64).unwrap(); + let component = ResidentMemoryComponentIdV1::new("test.resolution.foreign.v1").unwrap(); + let foreign = authority + .reserve_process_shared(component, NonZeroU64::new(1).unwrap()) + .unwrap(); + let result = reserve_resolution_vec(&mut values, 1, &mut |change| { + guard.resize(change.apply_to_u64(guard.charged_bytes)?) + }); + assert!(matches!( + result, + Err(CodeIndexProductionErrorV1::Publication(_)) + )); + assert_eq!(values, vec![1, 2, 3, 4]); + assert_eq!(values.capacity(), old_capacity); + assert_eq!(guard.charged_bytes, old as u64); + assert_eq!(authority.snapshot().used_bytes, old as u64 + 1); + drop(foreign); + let mut peak = 0; + reserve_resolution_vec(&mut values, 1, &mut |change| { + guard.resize(change.apply_to_u64(guard.charged_bytes)?)?; + peak = peak.max(authority.snapshot().used_bytes); + Ok(()) + }) + .unwrap(); + assert_eq!(peak, (old + replacement) as u64); + let actual = values.capacity() * size_of::(); + assert_eq!(guard.charged_bytes, actual as u64); + assert_eq!(authority.snapshot().used_bytes, actual as u64); + let charged = guard.charged_bytes; + let capacity = values.capacity(); + assert!( + reserve_resolution_vec(&mut values, usize::MAX, &mut |change| { + guard.resize(change.apply_to_u64(guard.charged_bytes)?) + }) + .is_err() + ); + assert_eq!(values.capacity(), capacity); + assert_eq!(guard.charged_bytes, charged); + drop(values); + drop(guard); + assert_eq!(authority.snapshot().used_bytes, 0); + } + + #[test] + fn hash_replacement_denial_preserves_owners_and_success_releases_the_old_body() { + let mut values = HashMap::::with_capacity(3); + for index in 0..values.capacity() { + values.insert(index, index + 1); + } + let old_capacity = values.capacity(); + let old = hash_table_bytes::<(usize, usize)>(old_capacity); + let replacement = hash_table_bytes::<(usize, usize)>( + hash_table_capacity::<(usize, usize)>(old_capacity + 1).unwrap(), + ); + let authority = memory((old + replacement) as u64); + let mut guard = ConstructionMemoryV1::new(authority.begin_operation()).unwrap(); + guard.resize(old as u64).unwrap(); + let component = ResidentMemoryComponentIdV1::new("test.resolution.foreign.v1").unwrap(); + let foreign = authority + .reserve_process_shared(component, NonZeroU64::new(1).unwrap()) + .unwrap(); + assert!( + reserve_resolution_map(&mut values, &mut |change| { + guard.resize(change.apply_to_u64(guard.charged_bytes)?) + }) + .is_err() + ); + assert_eq!(values.capacity(), old_capacity); + for index in 0..old_capacity { + assert_eq!(values.get(&index), Some(&(index + 1))); + } + assert_eq!(guard.charged_bytes, old as u64); + assert_eq!(authority.snapshot().used_bytes, old as u64 + 1); + drop(foreign); + let mut peak = 0; + reserve_resolution_map(&mut values, &mut |change| { + guard.resize(change.apply_to_u64(guard.charged_bytes)?)?; + peak = peak.max(authority.snapshot().used_bytes); + Ok(()) + }) + .unwrap(); + assert_eq!(peak, (old + replacement) as u64); + assert_eq!( + guard.charged_bytes, + hash_table_bytes::<(usize, usize)>(values.capacity()) as u64 + ); + drop(values); + drop(guard); + assert_eq!(authority.snapshot().used_bytes, 0); + } + + #[test] + fn tiny_hash_entries_use_the_allocated_class_and_checked_overflow() { + fn assert_class() { + let capacity = hash_table_capacity::(1).unwrap(); + let mut values = HashSet::::new(); + let mut live = 0; + reserve_resolution_set(&mut values, &mut |change| { + live = change.apply_to(live)?; + Ok(()) + }) + .unwrap(); + assert_eq!(values.capacity(), capacity); + assert_eq!(live, hash_table_bytes::(values.capacity())); + } + assert_class::(); + assert_class::(); + assert_class::<[u8; 3]>(); + assert_class::(); + assert_eq!(hash_table_capacity::(0).unwrap(), 0); + assert!(hash_table_capacity::(usize::MAX).is_err()); + assert!(hash_table_capacity::<[usize; 32]>(usize::MAX / 32).is_err()); + assert!( + ResolutionMemoryChangeV1::Retain(1) + .apply_to(usize::MAX) + .is_err() + ); + assert!(ResolutionMemoryChangeV1::Release(1).apply_to(0).is_err()); + let mut live = 9; + assert!( + ResolutionMemoryChangeV1::Retain(2) + .apply_with(&mut live, |_| { + Err(CodeIndexProductionErrorV1::Contract("refused".to_owned())) + }) + .is_err() + ); + assert_eq!( + live, 9, + "a refused parent reservation cannot change the local live base" + ); + } + #[test] + fn compact_fact_admission_precedes_clones_and_settles_after_source_release() { + let (store, _published) = published_fixture(); + let mut decoded = store.decode_active(&scope()).unwrap().unwrap(); + let file = decoded.files.remove(0); + drop(decoded); + let symbol = &file.artifacts.symbols[0]; + let original_symbol_owners = Arc::strong_count(symbol); + let denied = memory(1); + let mut guard = ConstructionMemoryV1::new(denied.begin_operation()).unwrap(); + let mut admissions = 0; + let result = CompactFileResolutionV1::from_file(&file, &mut |bytes| { + admissions += 1; + assert_eq!( + Arc::strong_count(symbol), + original_symbol_owners, + "admission must precede the first symbol-handle clone" + ); + guard.resize(bytes) + }); + assert!(matches!( + result, + Err(CodeIndexProductionErrorV1::Publication(_)) + )); + assert_eq!(admissions, 1); + assert_eq!(denied.snapshot().used_bytes, 0); + assert_eq!(Arc::strong_count(symbol), original_symbol_owners); + drop(guard); + + let authority = memory(64 * 1024 * 1024); + let mut guard = ConstructionMemoryV1::new(authority.begin_operation()).unwrap(); + let source_bytes = resolution_source_bytes(&file).unwrap() as u64; + let weak_source = Arc::downgrade(&file); + let facts = CompactFileResolutionV1::from_file(&file, &mut |copies| { + assert_eq!( + Arc::strong_count(&file.artifacts.symbols[0]), + original_symbol_owners + ); + guard.resize(source_bytes.checked_add(copies).unwrap()) + }) + .unwrap(); + let overlap = guard.charged_bytes; + assert_eq!(authority.snapshot().used_bytes, overlap); + drop(file); + assert!(weak_source.upgrade().is_none()); + let retained = facts.retained_bytes() as u64; + assert!( + retained < overlap, + "source and graph workspace must not accumulate" + ); + guard.resize(retained).unwrap(); + assert_eq!(authority.snapshot().used_bytes, retained); + drop(facts); + drop(guard); + assert_eq!(authority.snapshot().used_bytes, 0); + } + + #[test] + fn module_scoped_reexports_are_charged_in_source_and_compact_facts() { + let store = MemorySealedPublicationStoreV1::default(); + let mut owner = owner(&store); + let request = request( + &[ + ("src/lib.rs", "rust", "pub mod long;\n"), + ("src/long/mod.rs", "rust", "pub mod module;\n"), + ( + "src/long/module/mod.rs", + "rust", + "pub struct Item;\npub mod child;\n", + ), + ( + "src/long/module/child.rs", + "rust", + "pub(in crate::long::module) use super::Item as ScopedItem;\n\ + pub fn example(_: ScopedItem) {}\n", + ), + ], + 1_000_000, + ); + let _published = owner + .build_and_publish(request, &UninterruptibleCodeIndexControlV1) + .unwrap(); + let mut decoded = store.decode_active(&scope()).unwrap().unwrap(); + let file_index = decoded + .files + .iter() + .position(|file| file.authority.logical_path == "src/long/module/child.rs") + .unwrap(); + let mut file = decoded.files.remove(file_index); + drop(decoded); + let index = file + .artifacts + .imports + .iter() + .position(|import| { + matches!( + import.reexport_scope, + Some(ImportReexportScopeV1::Module(_)) + ) + }) + .expect("the real Rust extractor preserves module-scoped reexports"); + let authority = memory(64 * 1024 * 1024); + let mut with_guard = ConstructionMemoryV1::new(authority.begin_operation()).unwrap(); + let with_scope = + CompactFileResolutionV1::from_file(&file, &mut |bytes| with_guard.resize(bytes)) + .unwrap(); + let with_source = resolution_source_bytes(&file).unwrap(); + let scope = Arc::get_mut(&mut file).unwrap().artifacts.imports[index] + .reexport_scope + .take() + .unwrap(); + let ImportReexportScopeV1::Module(module) = scope else { + panic!("the selected reexport has an owned module path"); + }; + let without_source = resolution_source_bytes(&file).unwrap(); + assert_eq!(with_source - without_source, module.capacity()); + let mut without_guard = ConstructionMemoryV1::new(authority.begin_operation()).unwrap(); + let without_scope = + CompactFileResolutionV1::from_file(&file, &mut |bytes| without_guard.resize(bytes)) + .unwrap(); + let Some(ImportReexportScopeV1::Module(copied_module)) = + &with_scope.imports[index].reexport_scope + else { + panic!("compact facts retain the scoped import"); + }; + assert_eq!( + with_scope.retained_bytes() - without_scope.retained_bytes(), + copied_module.capacity() + ); + let with_bytes = with_scope.retained_bytes() - with_scope.owned_symbol_bytes(); + let without_bytes = without_scope.retained_bytes() - without_scope.owned_symbol_bytes(); + with_guard.resize(with_bytes as u64).unwrap(); + without_guard.resize(without_bytes as u64).unwrap(); + assert_eq!( + authority.snapshot().used_bytes, + (with_bytes + without_bytes) as u64 + ); + drop(with_scope); + drop(without_scope); + drop(with_guard); + drop(without_guard); + assert_eq!(authority.snapshot().used_bytes, 0); + } + + #[test] + fn sparse_decoded_and_lazy_facts_keep_only_their_actual_same_operation_charge() { + let (store, published) = published_fixture(); + let decoded = store.decode_active(&scope()).unwrap().unwrap(); + let file = &decoded.files[0]; + let authority = memory(64 * 1024 * 1024); + let operation = authority.begin_operation(); + let view = SparseFileV1::decoded( + &file.authority.logical_path, + file.extraction.language.as_str(), + Arc::clone(file), + operation.clone(), + ) + .unwrap(); + let loaded = view.loaded().unwrap(); + let expected = loaded.retained_bytes() - loaded.owned_symbol_bytes(); + assert_eq!(authority.snapshot().used_bytes, expected as u64); + drop(view); + assert_eq!(authority.snapshot().used_bytes, 0); + let denied = memory(1); + assert!(matches!( + SparseFileV1::decoded( + &file.authority.logical_path, + file.extraction.language.as_str(), + Arc::clone(file), + denied.begin_operation() + ), + Err(CodeIndexProductionErrorV1::Publication(_)) + )); + assert_eq!(denied.snapshot().used_bytes, 0); + + let parent = SealedParentGenerationV1::open(published.sealed_generation().clone()).unwrap(); + let descriptor = &parent.generation().file_segments[0]; + let row = &parent.snapshot().files[descriptor.file_key as usize]; + let view = SparseFileV1::carried( + &row.logical_path, + row.language.as_ref().unwrap().as_str(), + SparseFileSourceV1 { + parent: &parent, + descriptor, + file_occurrence_id: &row.file_occurrence_id, + generation_id: &parent.manifest().generation_id, + snapshot_digest: &parent.manifest().snapshot_digest, + construction_operation: operation, + }, + ); + assert_eq!(authority.snapshot().used_bytes, 0); + view.binding().unwrap(); + assert_eq!( + authority.snapshot().used_bytes, + view.loaded().unwrap().retained_bytes() as u64 + ); + drop(view); + assert_eq!(authority.snapshot().used_bytes, 0); + let denied_view = SparseFileV1::carried( + &row.logical_path, + row.language.as_ref().unwrap().as_str(), + SparseFileSourceV1 { + parent: &parent, + descriptor, + file_occurrence_id: &row.file_occurrence_id, + generation_id: &parent.manifest().generation_id, + snapshot_digest: &parent.manifest().snapshot_digest, + construction_operation: denied.begin_operation(), + }, + ); + assert!(matches!( + denied_view.binding(), + Err(CodeIndexProductionErrorV1::Publication(_)) + )); + assert!(denied_view.loaded().is_none()); + assert_eq!(denied.snapshot().used_bytes, 0); + } + + #[test] + fn cold_fact_vector_admission_precedes_canonical_segment_reads() { + let (_store, published) = published_fixture(); + let parent = SealedParentGenerationV1::open(published.sealed_generation().clone()).unwrap(); + let canonical = published.sealed_generation().segment_reader(); + let reads = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&reads); + let reader: Arc = Arc::new(move |segment, bytes| { + observed.fetch_add(1, Ordering::Relaxed); + canonical(segment, bytes) + }); + let authority = memory(1); + let mut guard = ConstructionMemoryV1::new(authority.begin_operation()).unwrap(); + let result = resolve_staged_generation( + parent.manifest(), + parent.snapshot(), + &parent.generation().file_segments, + reader, + &UninterruptibleCodeIndexControlV1, + &mut |bytes| guard.resize(bytes), + &mut |_| { + Err(CodeIndexProductionErrorV1::Contract( + "unexpected publication".to_owned(), + )) + }, + ); + assert!(matches!( + result, + Err(CodeIndexProductionErrorV1::Publication(_)) + )); + assert_eq!( + reads.load(Ordering::Relaxed), + 0, + "outer fact/order allocation admission must precede file decode and clones" + ); + assert_eq!(authority.snapshot().used_bytes, 0); } } diff --git a/crates/tracedecay-code-index/src/production/sealed_codec.rs b/crates/tracedecay-code-index/src/production/sealed_codec.rs index 5137a42df9..0f5cd04b08 100644 --- a/crates/tracedecay-code-index/src/production/sealed_codec.rs +++ b/crates/tracedecay-code-index/src/production/sealed_codec.rs @@ -1169,7 +1169,7 @@ pub(super) fn assemble_published_generation( .iter() .any(|file| !file.artifacts.go_method_sets.is_empty()) { - unresolved_calls.extend(go_satisfaction(&files, &ModuleImportIndexV1::new(&files)).gaps); + unresolved_calls.extend(go_satisfaction(&files, &ModuleImportIndexV1::new(&files)?)?.gaps); unresolved_calls.sort(); unresolved_calls.dedup(); } @@ -1228,7 +1228,7 @@ pub(super) fn assemble_published_generation( let (edges, edge_abstentions) = { let _span = tracing::trace_span!("code_index.sealed_decode.edge_evidence").entered(); - edge_evidence(&files, cross_file_edges) + edge_evidence(&files, cross_file_edges)? }; probe.sample(); let projection = { diff --git a/crates/tracedecay-code-index/src/production/sealed_parent.rs b/crates/tracedecay-code-index/src/production/sealed_parent.rs index f5bfb14ee8..62069656b1 100644 --- a/crates/tracedecay-code-index/src/production/sealed_parent.rs +++ b/crates/tracedecay-code-index/src/production/sealed_parent.rs @@ -7,7 +7,8 @@ //! reads. use std::collections::{BTreeMap, HashMap}; -use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use std::sync::{Arc, OnceLock}; +use tracedecay_runtime_core::resident_memory::ResidentMemoryOperationV1; use tracedecay_domain::{ CodeGenerationId, CodeGenerationManifestV1, FileOccurrenceId, ManifestDigest, @@ -22,7 +23,9 @@ use super::partitioned_codec::{ decode_file_segment, decode_generation_evidence, parse_partitioned_manifest, }; use super::resolution_index::ResolutionIndexReaderV1; -use super::resolution_view::ResolutionFileV1; +use super::resolution_view::{ + CompactFileResolutionV1, ResolutionFileFactsV1, ResolutionFileV1, resolution_source_bytes, +}; use super::sealed_codec::{FileScopeIdentityV1, restore_file_pages}; use super::{ ChunkPolicyRevisionSummaryV1, CodeIndexGenerationStatisticsV1, CodeIndexProductionErrorV1, @@ -65,6 +68,10 @@ impl CodeIndexSealedGenerationV1 { &self.manifest_bytes } + pub fn segment_reader(&self) -> Arc { + Arc::clone(&self.read_segment) + } + /// The authenticated manifest, snapshot, and statistics this generation /// seals, parsed without decoding any segment. pub fn metadata( @@ -74,6 +81,112 @@ impl CodeIndexSealedGenerationV1 { } } +/// A cold build's canonical file segments before its final manifest exists. +/// The source borrows the real generation identity; it creates no parent or +/// provisional sealed manifest and never retains a decoded file. +pub(super) struct StagedFileSourceV1<'a> { + manifest: &'a CodeGenerationManifestV1, + snapshot: &'a SanitizedCodeSnapshotV1, + files: &'a [PartitionedFileSegmentDescriptorV1], + read: Arc, + scope: FileScopeIdentityV1, +} + +impl<'a> StagedFileSourceV1<'a> { + pub(super) fn new( + manifest: &'a CodeGenerationManifestV1, + snapshot: &'a SanitizedCodeSnapshotV1, + files: &'a [PartitionedFileSegmentDescriptorV1], + read: Arc, + ) -> Result { + let mut previous = None; + for descriptor in files { + let file = snapshot + .files + .get(descriptor.file_key as usize) + .ok_or_else(|| contract("staged file segment is outside its snapshot"))?; + if file.disposition != tracedecay_domain::SnapshotFileDispositionV1::Present + || file.file_occurrence_id != descriptor.file_occurrence_id + || previous.is_some_and(|key| key >= descriptor.file_key) + { + return Err(contract( + "staged file descriptors disagree with their snapshot", + )); + } + previous = Some(descriptor.file_key); + } + if snapshot + .files + .iter() + .filter(|file| { + file.disposition == tracedecay_domain::SnapshotFileDispositionV1::Present + }) + .count() + != files.len() + { + return Err(contract( + "staged file descriptors do not cover every present file", + )); + } + Ok(Self { + manifest, + snapshot, + files, + read, + scope: FileScopeIdentityV1::of(manifest, snapshot), + }) + } + + pub(super) fn decode_file( + &self, + file_key: u32, + ) -> Result, CodeIndexProductionErrorV1> { + let index = self + .files + .binary_search_by_key(&file_key, |file| file.file_key) + .map_err(|_| contract("staged source has no such present file"))?; + let descriptor = &self.files[index]; + let snapshot_file = self + .snapshot + .files + .get(file_key as usize) + .ok_or_else(|| contract("staged file key is outside its snapshot"))?; + let mut bytes = Vec::new(); + (self.read)( + SealedGenerationSegmentReadV1::Whole { + digest: &descriptor.segment_digest, + size_bytes: descriptor.segment_size_bytes, + }, + &mut bytes, + )?; + let mut restored = Vec::new(); + let page = decode_file_segment( + descriptor, + &self.manifest.generation_id, + &self.manifest.snapshot_digest, + &self.scope, + &bytes, + &mut restored, + )?; + let file = restore_file_pages(vec![page])? + .pop() + .ok_or_else(|| contract("staged file segment restored no file"))?; + if file.authority.logical_path != snapshot_file.logical_path + || file.authority.content_digest != snapshot_file.content_digest + || snapshot_file + .language + .as_ref() + .map(|language| language.as_str()) + != Some(file.extraction.language.as_str()) + { + return Err(contract( + "staged file segment disagrees with its source snapshot", + )); + } + Ok(file) + } +} + /// A parsed sealed parent. pub(super) struct SealedParentGenerationV1 { sealed: CodeIndexSealedGenerationV1, @@ -299,54 +412,27 @@ fn contract(message: &str) -> CodeIndexProductionErrorV1 { CodeIndexProductionErrorV1::Contract(message.to_owned()) } -/// The first decode failure a lazy view recorded. -#[derive(Default)] -pub(super) struct DecodeFailureV1(Mutex>); - -impl DecodeFailureV1 { - pub(super) fn record(&self, error: CodeIndexProductionErrorV1) { - self.0 - .lock() - .unwrap_or_else(PoisonError::into_inner) - .get_or_insert(error); - } - - pub(super) fn take(&self) -> Option { - self.0.lock().unwrap_or_else(PoisonError::into_inner).take() - } -} - -/// One file of a successor generation as resolution reads it: its path and -/// language from the successor's snapshot, and its artifacts either already -/// decoded or decoded from the parent's carried segment the first time -/// resolution reads them. +/// One successor file retains only binding facts for this resolution pass. +/// Its parent bytes are read fallibly at the first operation that needs them. pub(super) struct SparseFileV1<'p> { logical_path: &'p str, language: &'p str, - artifacts: SparseArtifactsV1<'p>, + facts: OnceLock, + source: Option>, } -enum SparseArtifactsV1<'p> { - Decoded(Arc), - Carried { - decoded: OnceLock>, - source: SparseFileSourceV1<'p>, - }, +struct ChargedResolutionFactsV1 { + facts: CompactFileResolutionV1, + _memory: super::ConstructionMemoryV1, } -/// Where a carried file's artifacts decode from, and what stands in when -/// that decode fails. pub(super) struct SparseFileSourceV1<'p> { pub(super) parent: &'p SealedParentGenerationV1, pub(super) descriptor: &'p PartitionedFileSegmentDescriptorV1, pub(super) file_occurrence_id: &'p FileOccurrenceId, pub(super) generation_id: &'p CodeGenerationId, pub(super) snapshot_digest: &'p ManifestDigest, - /// An already decoded file a failed decode is read as. The failure is - /// recorded in `failure` and the build refuses before it publishes, so - /// nothing derived from the stand-in is sealed. - pub(super) stand_in: &'p Arc, - pub(super) failure: &'p DecodeFailureV1, + pub(super) construction_operation: ResidentMemoryOperationV1, } impl<'p> SparseFileV1<'p> { @@ -354,12 +440,33 @@ impl<'p> SparseFileV1<'p> { logical_path: &'p str, language: &'p str, artifacts: Arc, - ) -> Self { - Self { + construction_operation: ResidentMemoryOperationV1, + ) -> Result { + let mut memory = super::ConstructionMemoryV1::new(construction_operation)?; + let facts = + CompactFileResolutionV1::from_file(&artifacts, &mut |bytes| memory.resize(bytes))?; + drop(artifacts); + // Edited artifacts own these shared symbol records throughout the + // resolution; this view owns only its handles and copied rows. + let bytes = facts + .retained_bytes() + .saturating_sub(facts.owned_symbol_bytes()); + let bytes = u64::try_from(bytes).map_err(|_| contract("resolution facts exceed u64"))?; + if bytes > memory.charged_bytes { + return Err(contract( + "resolution facts exceed their admitted clone bound", + )); + } + memory.resize(bytes)?; + Ok(Self { logical_path, language, - artifacts: SparseArtifactsV1::Decoded(artifacts), - } + facts: OnceLock::from(ChargedResolutionFactsV1 { + facts, + _memory: memory, + }), + source: None, + }) } pub(super) fn carried( @@ -370,45 +477,13 @@ impl<'p> SparseFileV1<'p> { Self { logical_path, language, - artifacts: SparseArtifactsV1::Carried { - decoded: OnceLock::new(), - source, - }, - } - } - - /// The artifacts, when they are decoded already. - pub(super) fn loaded(&self) -> Option<&Arc> { - match &self.artifacts { - SparseArtifactsV1::Decoded(artifacts) => Some(artifacts), - SparseArtifactsV1::Carried { decoded, .. } => decoded.get(), - } - } - - pub(super) fn artifacts(&self) -> &Arc { - match &self.artifacts { - SparseArtifactsV1::Decoded(artifacts) => artifacts, - SparseArtifactsV1::Carried { decoded, source } => decoded.get_or_init(|| match source - .parent - .decode_file( - source.descriptor, - source.file_occurrence_id, - source.generation_id, - source.snapshot_digest, - ) { - Ok(file) => file, - Err(error) => { - source.failure.record(error); - Arc::clone(source.stand_in) - } - }), + facts: OnceLock::new(), + source: Some(source), } } -} -impl AsRef for SparseFileV1<'_> { - fn as_ref(&self) -> &FileGenerationArtifactsV1 { - self.artifacts() + pub(super) fn loaded(&self) -> Option<&CompactFileResolutionV1> { + self.facts.get().map(|charged| &charged.facts) } } @@ -416,8 +491,50 @@ impl ResolutionFileV1 for SparseFileV1<'_> { fn logical_path(&self) -> &str { self.logical_path } - fn language(&self) -> &str { self.language } + fn binding(&self) -> Result<&dyn ResolutionFileFactsV1, CodeIndexProductionErrorV1> { + if let Some(facts) = self.facts.get() { + return Ok(&facts.facts); + } + let source = self + .source + .as_ref() + .ok_or_else(|| contract("a successor file has no binding source"))?; + let file = source.parent.decode_file( + source.descriptor, + source.file_occurrence_id, + source.generation_id, + source.snapshot_digest, + )?; + let source_bytes = u64::try_from(resolution_source_bytes(&file)?) + .map_err(|_| contract("resolution source exceeds u64"))?; + let mut memory = super::ConstructionMemoryV1::new(source.construction_operation.clone())?; + let facts = CompactFileResolutionV1::from_file(&file, &mut |copies| { + let overlap = source_bytes + .checked_add(copies) + .ok_or_else(|| contract("resolution source/clone overlap exceeds u64"))?; + memory.resize(overlap) + })?; + drop(file); + let retained = u64::try_from(facts.retained_bytes()) + .map_err(|_| contract("resolution facts exceed u64"))?; + if retained > memory.charged_bytes { + return Err(contract( + "resolution facts exceed their admitted source/clone bound", + )); + } + memory.resize(retained)?; + // Concurrent reads can prepare the same immutable facts; only the + // winning compact value remains and every full decode drops here. + let _ = self.facts.set(ChargedResolutionFactsV1 { + facts, + _memory: memory, + }); + self.facts + .get() + .map(|charged| &charged.facts as &dyn ResolutionFileFactsV1) + .ok_or_else(|| contract("a successor file did not retain its binding facts")) + } } diff --git a/crates/tracedecay-code-index/src/production/sparse_differential_tests.rs b/crates/tracedecay-code-index/src/production/sparse_differential_tests.rs index 4cb83a61c3..a2c9ef309a 100644 --- a/crates/tracedecay-code-index/src/production/sparse_differential_tests.rs +++ b/crates/tracedecay-code-index/src/production/sparse_differential_tests.rs @@ -5,6 +5,7 @@ use std::path::Path; use tracedecay_domain::{EdgeAuthorityV1, LanguageId, SanitizationReceiptId, SensitivityLevelV1}; +use super::helpers::{edge_evidence, resolve_cross_file_references_charged}; use super::worker_tests::{ WorkerPublicationStore, worker_config, worker_id, worker_request_with_source, }; @@ -255,9 +256,12 @@ fn differential_counts(language: &str) -> DifferentialCounts { let tree = fixture_files(&Path::new(FIXTURE_ROOT).join(language)); let store = WorkerPublicationStore::default(); let mut owner = - CodeIndexProductionOwnerV1::new(worker_config(), store).expect("production owner"); + CodeIndexProductionOwnerV1::new(worker_config(), store.clone()).expect("production owner"); let base = publish(&mut owner, &tree, None, false, 1_000_000); - let base = base.decoded().expect("a cold seal holds its generation"); + let base = store + .decode_active(&CodeIndexGenerationScopeV1::for_snapshot(base.snapshot())) + .expect("decode cold fixture") + .expect("cold fixture active"); let mut counts = DifferentialCounts::default(); for index in 0..tree.len() { let (path, source) = &tree[index]; @@ -378,7 +382,10 @@ fn a_restored_parent_and_the_edit_over_it_resolve_nothing_whole() { let mut owner = CodeIndexProductionOwnerV1::new(worker_config(), store.clone()) .expect("production owner"); let parent = publish(&mut owner, &tree, None, false, 1_000_000); - let parent = parent.decoded().expect("a cold seal holds its generation"); + let parent = store + .decode_active(&CodeIndexGenerationScopeV1::for_snapshot(parent.snapshot())) + .expect("decode cold fixture") + .expect("cold fixture active"); assert!( parent .edges @@ -426,11 +433,12 @@ fn a_restored_parent_and_the_edit_over_it_resolve_nothing_whole() { .decode_active(&scope) .expect("the edit restores") .expect("an active edit"); + let full_resolution = resolve_cross_file_references_charged(&edited.files, &mut |_| Ok(())) + .expect("whole reference resolution"); + let (full_edges, _) = + edge_evidence(&edited.files, full_resolution.edges).expect("whole edge evidence"); assert_eq!( - edited.edges, - collect_edge_evidence(&edited.files) - .expect("whole edge evidence") - .0, + edited.edges, full_edges, "{language}: edges of the first build after the restart" ); } diff --git a/crates/tracedecay-code-index/src/production/sparse_increment.rs b/crates/tracedecay-code-index/src/production/sparse_increment.rs index f936386863..f27789c891 100644 --- a/crates/tracedecay-code-index/src/production/sparse_increment.rs +++ b/crates/tracedecay-code-index/src/production/sparse_increment.rs @@ -17,6 +17,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::sync::Arc; +use tracedecay_runtime_core::resident_memory::ResidentMemoryOperationV1; use sha2::{Digest, Sha256}; use tracedecay_domain::{ @@ -79,172 +80,6 @@ impl CodeIndexColdBuildReasonV1 { /// this many, the same share a layered graph refresh carries. const SPARSE_MAX_CHANGED_FILE_SHARE_DENOMINATOR: usize = 8; -/// One segment a sparse successor seals, in publication order. -enum SparseSegmentV1 { - File(ManifestDigest, Vec), - FileEvidence(ManifestDigest, Vec), - ResolutionIndex(ManifestDigest, Vec), - CodeGraphPage(u32, ManifestDigest, Vec), - EvidencePage(u32, ManifestDigest, Vec), - EvidenceCommit(ManifestDigest, u64, u32), -} - -impl SparseSegmentV1 { - fn of(publication: SealedGenerationSegmentPublicationV1<'_>) -> Self { - match publication { - SealedGenerationSegmentPublicationV1::File { digest, bytes } => { - Self::File(digest.clone(), bytes.to_vec()) - } - SealedGenerationSegmentPublicationV1::FileEvidence { digest, bytes } => { - Self::FileEvidence(digest.clone(), bytes.to_vec()) - } - SealedGenerationSegmentPublicationV1::ResolutionIndex { digest, bytes } => { - Self::ResolutionIndex(digest.clone(), bytes.to_vec()) - } - SealedGenerationSegmentPublicationV1::CodeGraphPage { - file_key, - page_digest, - bytes, - } => Self::CodeGraphPage(file_key, page_digest.clone(), bytes.to_vec()), - SealedGenerationSegmentPublicationV1::GenerationEvidencePage { - page_ordinal, - page_digest, - bytes, - } => Self::EvidencePage(page_ordinal, page_digest.clone(), bytes.to_vec()), - SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { - segment_digest, - segment_size_bytes, - page_count, - } => Self::EvidenceCommit(segment_digest.clone(), segment_size_bytes, page_count), - } - } - - fn publication(&self) -> SealedGenerationSegmentPublicationV1<'_> { - match self { - Self::File(digest, bytes) => { - SealedGenerationSegmentPublicationV1::File { digest, bytes } - } - Self::FileEvidence(digest, bytes) => { - SealedGenerationSegmentPublicationV1::FileEvidence { digest, bytes } - } - Self::ResolutionIndex(digest, bytes) => { - SealedGenerationSegmentPublicationV1::ResolutionIndex { digest, bytes } - } - Self::CodeGraphPage(file_key, page_digest, bytes) => { - SealedGenerationSegmentPublicationV1::CodeGraphPage { - file_key: *file_key, - page_digest, - bytes, - } - } - Self::EvidencePage(page_ordinal, page_digest, bytes) => { - SealedGenerationSegmentPublicationV1::GenerationEvidencePage { - page_ordinal: *page_ordinal, - page_digest, - bytes, - } - } - Self::EvidenceCommit(segment_digest, segment_size_bytes, page_count) => { - SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { - segment_digest, - segment_size_bytes: *segment_size_bytes, - page_count: *page_count, - } - } - } - } -} - -/// A successor generation sealed over its parent: its metadata, its -/// projection handoff, and the new segments and manifest a publication -/// store writes. -pub struct CodeIndexSparseGenerationV1 { - manifest: CodeGenerationManifestV1, - snapshot: SanitizedCodeSnapshotV1, - statistics: CodeIndexGenerationStatisticsV1, - chunk_count: u64, - projection: ProjectionPublicationHandoffV1, - segments: Vec, - manifest_bytes: Vec, - clone_payloads_reused: u64, - clone_payloads_computed: u64, - clone_stale_invalidations: u64, -} - -impl std::fmt::Debug for CodeIndexSparseGenerationV1 { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("CodeIndexSparseGenerationV1") - .field("generation_id", &self.manifest.generation_id) - .field("segments", &self.segments.len()) - .finish_non_exhaustive() - } -} - -impl CodeIndexSparseGenerationV1 { - pub fn manifest(&self) -> &CodeGenerationManifestV1 { - &self.manifest - } - - pub fn snapshot(&self) -> &SanitizedCodeSnapshotV1 { - &self.snapshot - } - - pub fn statistics(&self) -> &CodeIndexGenerationStatisticsV1 { - &self.statistics - } - - pub fn chunk_count(&self) -> u64 { - self.chunk_count - } - - pub fn projection(&self) -> &ProjectionPublicationHandoffV1 { - &self.projection - } - - pub fn clone_update_statistics(&self) -> (u64, u64, bool) { - ( - self.clone_payloads_reused, - self.clone_stale_invalidations, - self.clone_payloads_computed > 0 || self.clone_stale_invalidations > 0, - ) - } - - /// Every segment this successor wrote: its kind, content address, and - /// stored size. - #[cfg(test)] - pub(super) fn written_segments( - &self, - ) -> impl Iterator { - self.segments.iter().filter_map(|segment| match segment { - SparseSegmentV1::File(digest, bytes) => Some(("file", digest, bytes.len())), - SparseSegmentV1::FileEvidence(digest, bytes) => { - Some(("file_evidence", digest, bytes.len())) - } - SparseSegmentV1::ResolutionIndex(digest, bytes) => { - Some(("resolution_index", digest, bytes.len())) - } - SparseSegmentV1::CodeGraphPage(_, digest, bytes) => { - Some(("code_graph_page", digest, bytes.len())) - } - SparseSegmentV1::EvidencePage(..) | SparseSegmentV1::EvidenceCommit(..) => None, - }) - } - - /// Publish every new segment in order and return the manifest. - pub(super) fn replay( - &self, - mut publish_segment: impl FnMut( - SealedGenerationSegmentPublicationV1<'_>, - ) -> Result<(), CodeIndexProductionErrorV1>, - ) -> Result, CodeIndexProductionErrorV1> { - for segment in &self.segments { - publish_segment(segment.publication())?; - } - Ok(self.manifest_bytes.clone()) - } -} - /// Everything a sparse build reads besides its parent. pub(super) struct SparseBuildV1<'a> { pub(super) config: &'a CodeIndexProductionConfigV1, @@ -259,6 +94,7 @@ pub(super) struct SparseBuildV1<'a> { pub(super) captured_files: &'a BTreeMap, pub(super) target_projection_key: &'a ProjectionKeyV1, pub(super) control: &'a dyn CodeIndexExecutionControlV1, + pub(super) construction_operation: &'a ResidentMemoryOperationV1, } fn present_paths(files: &[SanitizedCodeFileV1]) -> BTreeSet<&str> { @@ -287,6 +123,33 @@ pub(super) fn count(value: usize) -> Result { u64::try_from(value).map_err(|_| contract("sparse generation count exceeds u64")) } +/// Charge the same shared chunk and symbol allocations once across both +/// sides of the edited set, using the canonical owned-row accounting. +fn edited_artifact_bytes(edited: &[EditedFileV1<'_>]) -> usize { + let mut files = HashSet::new(); + let mut chunks = HashSet::new(); + let mut symbols = HashSet::new(); + let mut bytes = edited + .len() + .saturating_mul(std::mem::size_of::>()); + for file in edited.iter().flat_map(|file| [&file.before, &file.after]) { + if files.insert(Arc::as_ptr(file)) { + bytes = bytes.saturating_add(super::resident_bytes::file_bytes(file)); + } + for chunk in &file.artifacts.chunks.chunks { + if chunks.insert(Arc::as_ptr(chunk)) { + bytes = bytes.saturating_add(super::resident_bytes::chunk_bytes(chunk)); + } + } + for symbol in &file.artifacts.symbols { + if symbols.insert(Arc::as_ptr(symbol)) { + bytes = bytes.saturating_add(super::resident_bytes::symbol_bytes(symbol)); + } + } + } + bytes +} + impl SparseBuildV1<'_> { /// The successor of `parent` for `snapshot`, or why it must build cold. #[tracing::instrument(name = "code_index.build.sparse", level = "trace", skip_all)] @@ -296,11 +159,13 @@ impl SparseBuildV1<'_> { plan: &GenerationIncrementPlanV1, mut manifest: CodeGenerationManifestV1, snapshot: &SanitizedCodeSnapshotV1, + preparation: &mut impl CodeIndexPreparationPort, ) -> Result< - Result, + Result, CodeIndexProductionErrorV1, > { let control = self.control; + let mut memory = ConstructionMemoryV1::new(self.construction_operation.clone())?; if plan.is_full_rebuild() { return Ok(Err(CodeIndexColdBuildReasonV1::FullRebuild)); } @@ -341,6 +206,8 @@ impl SparseBuildV1<'_> { } lexical_page_source::checkpoint(control)?; let edited = self.extract_edited(parent, &edited_rows, &manifest)?; + let artifact_bytes = edited_artifact_bytes(&edited); + memory.resize(count(artifact_bytes)?)?; if edited .iter() .any(|file| moves_name_lookups(&file.before, &file.after)) @@ -357,7 +224,19 @@ impl SparseBuildV1<'_> { let generation_id = manifest.generation_id.clone(); let parent_id = parent.manifest().generation_id.clone(); - let changes = edited_chunk_changes(parent, &edited, &generation_id, present_count)?; + let changes = edited_chunk_changes( + parent, + &edited, + &generation_id, + present_count, + &mut |change_bytes| { + memory.resize( + count(artifact_bytes)? + .checked_add(change_bytes) + .ok_or_else(|| contract("sparse construction charge exceeds u64"))?, + ) + }, + )?; let parent_full_replay = parent .manifest() .source_commitments @@ -388,16 +267,8 @@ impl SparseBuildV1<'_> { ) .emit(&manifest) .map_err(CodeIndexProductionErrorV1::Capability)?; - let projection = project_for_publication(projection_request( - Some(self.target_projection_key.clone()), - self.target_projection_key.clone(), - changes.set, - )?) - .map_err(CodeIndexProductionErrorV1::Projection)?; - lexical_page_source::checkpoint(control)?; - let sealed = SealedSuccessorV1::new(parent, &manifest, snapshot, &edited)?; - let resolution = sealed.resolve(parent, &edited)?; + let resolution = sealed.resolve(parent, &edited, self.construction_operation)?; lexical_page_source::checkpoint(control)?; let lineage = edited_lineage(&parent_id, &generation_id, &edited)?; let carried_symbols = parent @@ -412,10 +283,10 @@ impl SparseBuildV1<'_> { .ok_or_else(|| contract("sealed parent counts fewer symbols than it carries"))?; let lineage_prior = (carried_symbols > 0 || !lineage.is_empty()).then_some(&parent_id); - let mut segments = Vec::new(); let mut publish = |publication: SealedGenerationSegmentPublicationV1<'_>| { - segments.push(SparseSegmentV1::of(publication)); - Ok(()) + preparation + .write_segment(publication) + .map_err(CodeIndexProductionErrorV1::Publication) }; let file_segments = sealed.file_segments(parent, &edited, &mut publish)?; let (file_evidence, cross_file_edges) = sealed.file_evidence( @@ -427,6 +298,8 @@ impl SparseBuildV1<'_> { &mut publish, )?; let code_graph_pages = sealed.graph_pages(parent, &edited, &resolution, &mut publish)?; + let statistics = successor_statistics(parent, &edited, &cross_file_edges, &resolution)?; + drop(resolution); let resolution_index = reseal_resolution_index( &parent.resolution_index()?, &edited @@ -439,6 +312,17 @@ impl SparseBuildV1<'_> { .collect::>(), &mut publish, )?; + let projection = project_for_publication(projection_request( + Some(self.target_projection_key.clone()), + self.target_projection_key.clone(), + changes.set, + )?) + .map_err(CodeIndexProductionErrorV1::Projection)?; + memory.resize(count(artifact_bytes.saturating_add( + super::resident_bytes::projection_heap_bytes(&projection), + ))?)?; + lexical_page_source::checkpoint(control)?; + let rosters = FileChunkRostersV1::new( edited .iter() @@ -456,7 +340,6 @@ impl SparseBuildV1<'_> { &mut publish, )?; drop(rosters); - let statistics = successor_statistics(parent, &edited, &cross_file_edges, &resolution)?; let chunk_policy = edited .iter() .flat_map(|file| file.after.artifacts.chunks.chunks.iter()) @@ -492,18 +375,19 @@ impl SparseBuildV1<'_> { computed = computed.saturating_add(file.clone_stats.computed); stale = stale.saturating_add(file.stale_invalidations); } - Ok(Ok(CodeIndexSparseGenerationV1 { - manifest, - snapshot: snapshot.clone(), - statistics, - chunk_count: changes.chunk_count, + let mut publication = CodeIndexSealedPublicationV1::from_prepared( + Arc::from(manifest_bytes), projection, - segments, - manifest_bytes, - clone_payloads_reused: reused, - clone_payloads_computed: computed, - clone_stale_invalidations: stale, - })) + None, + (reused, computed, stale), + )?; + drop(edited); + memory.resize(count(super::resident_bytes::sealed_publication_bytes( + &publication, + super::resident_bytes::chunk_policy_heap_bytes(&chunk_policy), + ))?)?; + publication.construction_memory = Some(Arc::new(memory)); + Ok(Ok(publication)) } /// Decode each edited file as the parent sealed it and extract it anew. @@ -570,6 +454,7 @@ fn edited_chunk_changes( edited: &[EditedFileV1<'_>], generation_id: &CodeGenerationId, present_files: usize, + retain_changes: &mut impl FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, ) -> Result { let before = edited .iter() @@ -636,6 +521,17 @@ fn edited_chunk_changes( reused_count, reused_digest, }; + let changed_bytes = super::resident_bytes::vec_bytes( + &set.added_or_changed, + set.added_or_changed.capacity(), + super::resident_bytes::changed_chunk_heap_bytes, + ) + .saturating_add(super::resident_bytes::vec_bytes( + &set.deleted, + set.deleted.capacity(), + super::resident_bytes::changed_chunk_heap_bytes, + )); + retain_changes(count(changed_bytes)?)?; set.seal().map_err(|error| contract(&error.to_string()))?; Ok(EditedChunkChangesV1 { set, @@ -767,3 +663,105 @@ pub(super) fn lane_digest( ManifestDigest::from_sha256_bytes(&hasher.finalize()) .map_err(|error| contract(&error.to_string())) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::EditedFileV1; + use crate::clones::ClonePayloadBuildStatsV1; + use crate::production::sealed_parent::SealedParentGenerationV1; + use crate::production::sparse_increment_tests::{owner, request, scope}; + use crate::production::sparse_successor::SealedSuccessorV1; + use crate::production::{MemorySealedPublicationStoreV1, UninterruptibleCodeIndexControlV1}; + + #[test] + fn sparse_resolved_rows_keep_their_operation_reservation_through_sealing() { + let store = MemorySealedPublicationStoreV1::default(); + let mut owner = owner(&store); + let seed_request = request( + &[ + ("src/a.rs", "rust", "pub fn run() { crate::b::helper(); }\n"), + ("src/b.rs", "rust", "pub fn helper() {}\n"), + ("src/lib.rs", "rust", "pub mod a; pub mod b;\n"), + ], + 1_000_000, + ); + let memory = Arc::clone(&seed_request.resident_memory); + let published = owner + .build_and_publish(seed_request, &UninterruptibleCodeIndexControlV1) + .unwrap(); + let prior = store.decode_active(&scope()).unwrap().unwrap(); + let parent = SealedParentGenerationV1::open(published.sealed_generation().clone()).unwrap(); + let mut successor_request = request( + &[ + ( + "src/a.rs", + "rust", + "pub fn run_again() { crate::b::helper(); }\n", + ), + ("src/b.rs", "rust", "pub fn helper() {}\n"), + ("src/lib.rs", "rust", "pub mod a; pub mod b;\n"), + ], + 2_000_000, + ); + successor_request.resident_memory = Arc::clone(&memory); + let successor_publication = owner + .build_and_publish(successor_request, &UninterruptibleCodeIndexControlV1) + .unwrap(); + let decoded = store.decode_active(&scope()).unwrap().unwrap(); + assert_eq!( + decoded.manifest.generation_id, + successor_publication.publication().manifest().generation_id, + ); + let file = decoded + .snapshot + .files + .iter() + .find(|file| file.logical_path == "src/a.rs") + .unwrap(); + let artifact = decoded + .files + .iter() + .find(|file| file.authority.logical_path == "src/a.rs") + .unwrap(); + let edited = vec![EditedFileV1 { + file, + before: Arc::clone( + prior + .files + .iter() + .find(|file| file.authority.logical_path == "src/a.rs") + .unwrap(), + ), + after: Arc::clone(artifact), + clone_stats: ClonePayloadBuildStatsV1::default(), + stale_invalidations: 0, + }]; + let successor = + SealedSuccessorV1::new(&parent, &decoded.manifest, &decoded.snapshot, &edited).unwrap(); + let operation = memory.begin_operation(); + let before = memory.snapshot().used_bytes; + let resolution = successor.resolve(&parent, &edited, &operation).unwrap(); + assert!(resolution.files.values().any(|file| !file.edges.is_empty())); + let held = resolution._output_memory.as_ref().unwrap().charged_bytes; + assert!(held > 0); + assert_eq!(memory.snapshot().used_bytes, before + held); + let mut staged = 0; + successor + .graph_pages(&parent, &edited, &resolution, &mut |_| { + assert_eq!(memory.snapshot().used_bytes, before + held); + staged += 1; + Ok(()) + }) + .unwrap(); + assert!(staged > 0); + assert_eq!( + memory.snapshot().used_bytes, + before + held, + "sealing must retain the resolution reservation" + ); + drop(resolution); + assert_eq!(memory.snapshot().used_bytes, before); + } +} diff --git a/crates/tracedecay-code-index/src/production/sparse_increment_tests.rs b/crates/tracedecay-code-index/src/production/sparse_increment_tests.rs index 04b4b2d3aa..d9edf714d7 100644 --- a/crates/tracedecay-code-index/src/production/sparse_increment_tests.rs +++ b/crates/tracedecay-code-index/src/production/sparse_increment_tests.rs @@ -3,9 +3,22 @@ //! statistics, coverage, and resolution index, and the same restored edges //! and call limitations. +use std::num::NonZeroU64; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use sha2::Digest as _; use tracedecay_domain::{LanguageId, ProjectionKeyV1, ProjectionKindV1, SanitizationReceiptId}; - +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, ProcessResidentSampleV1, ResidentMemoryPressureV1, + detected_process_resident_memory_limit_v1, +}; + +use super::module_resolution::ModuleImportIndexV1; +use super::resolution_view::SymbolsByNameV1; +use super::sealed_parent::SealedParentGenerationV1; +use super::sparse_resolution::{SparseDefinitionWindowV1, SparseSymbolsByNameV1}; +use super::typescript_resolution::ImportBindingOutcomeV1; use super::worker_tests::{worker_config, worker_id}; use super::*; @@ -39,7 +52,7 @@ fn request_capturing( request } -fn request(files: &[(&str, &str, &str)], sealed_at: i64) -> CodeIndexBuildRequestV1 { +pub(super) fn request(files: &[(&str, &str, &str)], sealed_at: i64) -> CodeIndexBuildRequestV1 { let mut rows = files .iter() .map(|(path, language, source)| SanitizedCodeFileV1 { @@ -60,6 +73,9 @@ fn request(files: &[(&str, &str, &str)], sealed_at: i64) -> CodeIndexBuildReques .collect::>() .concat(); CodeIndexBuildRequestV1 { + resident_memory: Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot: SanitizedCodeSnapshotV1 { repository: worker_id("repository.worker"), worktree: None, @@ -98,7 +114,7 @@ fn request(files: &[(&str, &str, &str)], sealed_at: i64) -> CodeIndexBuildReques type Owner = CodeIndexProductionOwnerV1; -fn owner(store: &MemorySealedPublicationStoreV1) -> Owner { +pub(super) fn owner(store: &MemorySealedPublicationStoreV1) -> Owner { CodeIndexProductionOwnerV1::new(worker_config(), store.clone()).expect("production owner") } @@ -115,7 +131,7 @@ fn publish( .expect("published generation") } -fn scope() -> CodeIndexGenerationScopeV1 { +pub(super) fn scope() -> CodeIndexGenerationScopeV1 { CodeIndexGenerationScopeV1::for_snapshot(&request(&[], 0).snapshot) } @@ -194,9 +210,16 @@ fn assert_sparse_matches_cold( None, "an in-place edit seals over its parent" ); - assert!( - sparse.decoded().is_none(), - "a sparse successor holds no decoded generation" + assert_eq!( + store + .load_active(&scope()) + .expect("active publication") + .expect("active successor") + .metadata() + .expect("verified metadata") + .manifest() + .generation_id, + sparse.manifest().generation_id ); let cold_store = MemorySealedPublicationStoreV1::default(); @@ -405,9 +428,13 @@ fn import_and_file_set_changes_build_cold() { added.cold_reason(), Some(CodeIndexColdBuildReasonV1::FilesAddedOrRemoved) ); - assert!( - added.decoded().is_some(), - "a cold build holds its decoded generation" + assert_eq!( + store + .decode_active(&scope()) + .expect("decode cold publication") + .expect("cold publication active") + .statistics, + *added.metadata().generation_statistics() ); } @@ -549,15 +576,25 @@ fn ambiguity_census_preserves_unknown_parent_count() { .expect("statistics object") .remove("ambiguous_name_drops"); legacy.statistics = serde_json::from_value(statistics).expect("legacy statistics decode"); + let mut preparation = store + .prepare(&scope(), Some(&initial.manifest().generation_id)) + .expect("prepare legacy publication"); + let manifest_bytes = legacy + .encode_partitioned_sealed(|segment| { + preparation + .write_segment(segment) + .map_err(CodeIndexProductionErrorV1::Publication) + }) + .expect("seal legacy publication"); + let publication = CodeIndexSealedPublicationV1::from_prepared( + Arc::from(manifest_bytes), + legacy.projection().clone(), + Some(CodeIndexColdBuildReasonV1::NoParent), + (0, 0, 0), + ) + .expect("verified legacy publication"); store - .publish_atomically( - &scope(), - Some(&initial.manifest().generation_id), - &CodeIndexSealedPublicationV1::Cold( - Arc::new(legacy), - CodeIndexColdBuildReasonV1::NoParent, - ), - ) + .commit(&mut preparation, &publication) .expect("legacy publication"); let after = ambiguous_rust_tree(NO_AMBIGUOUS_CALL, "pub fn choice() -> u32 { 1 }"); let sparse = publish(&mut owner(&store), &after, 1_200_000); @@ -717,3 +754,256 @@ fn a_language_changed_file_without_captured_bytes_is_refused() { CodeIndexProductionErrorV1::Input(CodeIndexInputErrorV1::MissingCapturedFile) )); } + +#[test] +fn sparse_lookup_window_shares_canonical_page_and_releases_it() { + let store = MemorySealedPublicationStoreV1::default(); + let mut owner = owner(&store); + let request = request( + &[( + "src/lib.rs", + "rust", + "pub fn alpha() {}\npub fn beta() {}\n", + )], + 1_000_000, + ); + let memory = Arc::clone(&request.resident_memory); + let published = owner + .build_and_publish(request, &UninterruptibleCodeIndexControlV1) + .expect("publish canonical lookup fixture"); + let sealed = published.sealed_generation(); + let canonical = sealed.segment_reader(); + let reads = Arc::new(AtomicUsize::new(0)); + let fail = Arc::new(AtomicBool::new(false)); + let observed = Arc::clone(&reads); + let failure = Arc::clone(&fail); + let reader: Arc = Arc::new(move |segment, bytes| { + observed.fetch_add(1, Ordering::Relaxed); + if failure.load(Ordering::Relaxed) { + return Err(CodeIndexProductionErrorV1::Contract( + "reader failure".to_owned(), + )); + } + canonical(segment, bytes) + }); + let parent = SealedParentGenerationV1::open(CodeIndexSealedGenerationV1::new( + Arc::from(sealed.manifest_bytes()), + reader, + )) + .expect("open canonical parent"); + let mut present = parent + .snapshot() + .files + .iter() + .filter(|file| file.disposition == SnapshotFileDispositionV1::Present) + .collect::>(); + present.sort_by(|left, right| left.file_occurrence_id.cmp(&right.file_occurrence_id)); + let paths = present + .iter() + .enumerate() + .map(|(index, file)| (file.logical_path.as_str(), index)) + .collect::>(); + let index = parent + .resolution_index() + .expect("canonical resolution index"); + let operation = memory.begin_operation(); + let window = SparseDefinitionWindowV1::new(&index, &operation).expect("lookup window"); + let before = + SparseSymbolsByNameV1::new(&index, &paths, &window, std::iter::empty(), &operation) + .expect("before overlay"); + let after = SparseSymbolsByNameV1::new(&index, &paths, &window, std::iter::empty(), &operation) + .expect("after overlay"); + assert!(before.get("alpha").expect("first name").is_some()); + assert!(after.get("beta").expect("second name").is_some()); + assert_eq!( + reads.load(Ordering::Relaxed), + 1, + "before and after share one canonical page" + ); + window.release().expect("release first window"); + assert!(after.get("alpha").expect("next window").is_some()); + assert_eq!( + reads.load(Ordering::Relaxed), + 2, + "next window reads the canonical page again" + ); + window.release().expect("release second window"); + fail.store(true, Ordering::Relaxed); + assert!( + matches!(after.get("beta"), Err(CodeIndexProductionErrorV1::Contract(message)) if message == "reader failure") + ); + assert_eq!( + reads.load(Ordering::Relaxed), + 3, + "reader failure reaches the canonical loader" + ); +} + +#[test] +fn ruby_require_memo_releases_its_charged_workspace_and_retries_errors() { + let store = MemorySealedPublicationStoreV1::default(); + let mut owner = owner(&store); + publish( + &mut owner, + &[ + ( + "lib/a.rb", + "ruby", + "require_relative 'b'\ndef run\n Inner.bar\nend\n", + ), + ( + "lib/b.rb", + "ruby", + "require_relative 'c'\nmodule Inner\n def self.bar\n 3\n end\nend\n", + ), + ("lib/c.rb", "ruby", "require_relative 'a'\nC = 1\n"), + ], + 1_000_000, + ); + let decoded = store.decode_active(&scope()).unwrap().unwrap(); + let files = &decoded.files; + let index = files + .iter() + .position(|file| file.authority.logical_path == "lib/a.rb") + .unwrap(); + let reference = files[index] + .artifacts + .unresolved_references + .iter() + .find(|reference| reference.reference_name == "Inner.bar") + .unwrap(); + let mut modules = ModuleImportIndexV1::new(files).unwrap(); + let refuse = || CodeIndexProductionErrorV1::Contract("closure admission refused".to_owned()); + assert!( + matches!(modules.call_outcome(index, reference, &mut |_| Err(refuse())), + Err(CodeIndexProductionErrorV1::Contract(message)) if message == "closure admission refused") + ); + let mut charged = 0; + let first = modules + .call_outcome(index, reference, &mut |bytes| { + charged = bytes.apply_to(charged)?; + Ok(()) + }) + .unwrap(); + let Some(ImportBindingOutcomeV1::Bound(target, symbol)) = first else { + panic!("the canonical require cycle binds Inner.bar"); + }; + assert!( + charged > 0, + "the closure must be admitted before allocation" + ); + assert!( + matches!(modules.call_outcome(index, reference, &mut |_| Err(refuse())).unwrap(), + Some(ImportBindingOutcomeV1::Bound(index, ref row)) if index == target && row.occurrence == symbol.occurrence) + ); + modules.release_file_memo(index); + assert!( + matches!(modules.call_outcome(index, reference, &mut |_| Err(refuse())), + Err(CodeIndexProductionErrorV1::Contract(message)) if message == "closure admission refused") + ); + assert!( + matches!(modules.call_outcome(index, reference, &mut |_| Ok(())).unwrap(), + Some(ImportBindingOutcomeV1::Bound(index, ref row)) if index == target && row.occurrence == symbol.occurrence) + ); +} + +#[test] +fn carried_evidence_visitors_release_each_files_scratch_before_the_next_file() { + let mut sources = (0..24).map(|index| ( + format!("lib/caller_{index}.rb"), + "ruby", + format!("require_relative 'target'\nrequire_relative 'missing'\ndef run_{index}\n Target.helper\n Missing.call\nend\n"), + )).collect::>(); + sources.push(( + "lib/target.rb".to_owned(), + "ruby", + "module Target\n def self.helper\n 1\n end\nend\n".to_owned(), + )); + let rows = sources + .iter() + .map(|(path, language, source)| (path.as_str(), *language, source.as_str())) + .collect::>(); + let store = MemorySealedPublicationStoreV1::default(); + let mut owner = owner(&store); + let request = request(&rows, 1_000_000); + let fixture_memory = Arc::clone(&request.resident_memory); + let published = owner + .build_and_publish(request, &UninterruptibleCodeIndexControlV1) + .unwrap(); + let parent = SealedParentGenerationV1::open(published.sealed_generation().clone()).unwrap(); + let decoded = store.decode_active(&scope()).unwrap().unwrap(); + let fixture_operation = fixture_memory.begin_operation(); + let baseline = fixture_memory.snapshot().used_bytes; + let mut one_file_peak = 0; + for descriptor in &parent.generation().file_segments { + let file = &parent.snapshot().files[descriptor.file_key as usize]; + let facts = decoded + .files + .iter() + .find(|facts| facts.authority.logical_path == file.logical_path) + .unwrap(); + let Some(evidence) = parent.file_evidence(descriptor.file_key).unwrap() else { + continue; + }; + evidence + .for_each_sealed_edge(facts.as_ref(), &fixture_operation, &mut |_, _, _, _, _| { + one_file_peak = one_file_peak.max(fixture_memory.snapshot().used_bytes - baseline); + Ok(()) + }) + .unwrap(); + evidence + .for_each_unresolved_call(facts.as_ref(), &fixture_operation, &mut |_| { + one_file_peak = one_file_peak.max(fixture_memory.snapshot().used_bytes - baseline); + Ok(()) + }) + .unwrap(); + assert_eq!(fixture_memory.snapshot().used_bytes, baseline); + } + let limit = NonZeroU64::new(one_file_peak).expect("real fixture has evidence scratch"); + let memory = Arc::new(ProcessResidentMemoryV1::with_pressure( + limit, + Arc::new(ResidentMemoryPressureV1::with_sampler( + limit, + Arc::new(|| { + Some(ProcessResidentSampleV1 { + resident_bytes: 0, + unreclaimable_bytes: 0, + swapped_bytes: 0, + cgroup_headroom_bytes: None, + }) + }), + )), + )); + let operation = memory.begin_operation(); + let mut visited_edges = 0; + let mut visited_calls = 0; + for descriptor in &parent.generation().file_segments { + let file = &parent.snapshot().files[descriptor.file_key as usize]; + let facts = decoded + .files + .iter() + .find(|facts| facts.authority.logical_path == file.logical_path) + .unwrap(); + let Some(evidence) = parent.file_evidence(descriptor.file_key).unwrap() else { + continue; + }; + evidence + .for_each_sealed_edge(facts.as_ref(), &operation, &mut |_, _, _, _, _| { + visited_edges += 1; + assert!(memory.snapshot().used_bytes <= one_file_peak); + Ok(()) + }) + .unwrap(); + assert_eq!(memory.snapshot().used_bytes, 0); + evidence + .for_each_unresolved_call(facts.as_ref(), &operation, &mut |_| { + visited_calls += 1; + assert!(memory.snapshot().used_bytes <= one_file_peak); + Ok(()) + }) + .unwrap(); + assert_eq!(memory.snapshot().used_bytes, 0); + } + assert!(visited_edges >= 24); + assert!(visited_calls >= 24); +} diff --git a/crates/tracedecay-code-index/src/production/sparse_resolution.rs b/crates/tracedecay-code-index/src/production/sparse_resolution.rs index ab7c83f706..de96062e73 100644 --- a/crates/tracedecay-code-index/src/production/sparse_resolution.rs +++ b/crates/tracedecay-code-index/src/production/sparse_resolution.rs @@ -16,7 +16,8 @@ //! into. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map}; -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, Mutex}; +use tracedecay_runtime_core::resident_memory::ResidentMemoryOperationV1; use tracedecay_code_extraction::{ImportModuleKindV1, ImportNamespaceV1, ImportReexportScopeV1}; use tracedecay_domain::{ @@ -29,12 +30,15 @@ use crate::graph_projection::unresolved_call_limitations; use crate::lineage::LineageSymbolRecordV1; use super::helpers::{ - edge_order, resolve_selected_cross_file_references, selected_references, - unresolved_import_calls, + CrossFileResolutionV1, ResolutionModulesV1, edge_order, resolve_selected_with_modules, + selected_references, unresolved_import_calls_with_modules, }; -use super::resolution_index::{ResolutionIndexReaderV1, name_segments}; -use super::resolution_view::{NamedSymbolV1, ResolutionFileV1, SymbolsByNameV1}; -use super::sealed_parent::{DecodeFailureV1, SealedParentGenerationV1, SparseFileV1}; +use super::resolution_index::{DefinitionPageV1, ResolutionIndexReaderV1, name_segments}; +use super::resolution_view::ResolutionMemoryChangeV1; +use super::resolution_view::{ + NamedSymbolV1, ResolutionFileFactsV1, ResolutionFileV1, SymbolsByNameV1, +}; +use super::sealed_parent::{SealedParentGenerationV1, SparseFileV1}; use super::{CodeIndexProductionErrorV1, FileGenerationArtifactsV1, collect_bounded_ordered}; type SiteV1<'a> = (&'a SymbolOccurrenceId, SourceSpan); @@ -255,25 +259,128 @@ impl ChangedNamesV1 { } /// [`SymbolsByNameV1`] over a successor: the parent's resolution index with -/// the edited files' rows replaced by their successors' symbols. Each page is -/// read once, the first time a name on it is looked up. +/// the edited files' rows replaced by their successors' symbols. A lookup +/// releases its decoded page after taking the matching rows. pub(super) struct SparseSymbolsByNameV1<'r> { index: &'r ResolutionIndexReaderV1<'r>, + window: &'r SparseDefinitionWindowV1<'r>, index_of_path: &'r HashMap<&'r str, usize>, edited: HashMap>, edited_paths: HashSet<&'r str>, - pages: Vec>>>, - failure: &'r DecodeFailureV1, + /// Only the identities needed by emitted edge evidence survive a page + /// lookup. No decoded definition page or symbol record is cached here. + targets: std::sync::Mutex, +} + +/// Shared only by the before/after lookup overlays for one selected-file +/// window. Canonical page payloads never outlive that window. +pub(super) struct SparseDefinitionWindowV1<'r> { + index: &'r ResolutionIndexReaderV1<'r>, + operation: ResidentMemoryOperationV1, + state: Mutex, +} + +struct SparseDefinitionWindowStateV1 { + pages: BTreeMap>, + memory: super::ConstructionMemoryV1, +} + +impl<'r> SparseDefinitionWindowV1<'r> { + pub(super) fn new( + index: &'r ResolutionIndexReaderV1<'r>, + construction_operation: &ResidentMemoryOperationV1, + ) -> Result { + Ok(Self { + index, + operation: construction_operation.clone(), + state: Mutex::new(SparseDefinitionWindowStateV1 { + pages: BTreeMap::new(), + memory: super::ConstructionMemoryV1::new(construction_operation.clone())?, + }), + }) + } + + fn page(&self, page: usize) -> Result, CodeIndexProductionErrorV1> { + let mut state = self + .state + .lock() + .map_err(|_| contract("resolution page window lock is poisoned"))?; + if let Some(rows) = state.pages.get(&page) { + return Ok(Arc::clone(rows)); + } + let held = state.memory.charged_bytes; + state.memory.resize( + held.checked_add(self.index.definition_page_read_bytes(page)?) + .ok_or_else(|| contract("resolution page window charge exceeds u64"))?, + )?; + let rows = self.index.definition_page(page)?; + let mut bytes = super::resident_bytes::btree_bytes( + rows.len(), + std::mem::size_of::<(String, BTreeMap>>)>(), + ); + for (name, files) in &rows { + bytes = bytes.saturating_add(name.capacity()).saturating_add( + super::resident_bytes::btree_bytes( + files.len(), + std::mem::size_of::<(String, Vec>)>(), + ), + ); + for (path, symbols) in files { + bytes = bytes.saturating_add(path.capacity()).saturating_add( + super::resident_bytes::vec_bytes(symbols, symbols.capacity(), |symbol| { + super::resident_bytes::symbol_bytes(symbol) + }), + ); + } + } + let before = super::resident_bytes::btree_bytes( + state.pages.len(), + std::mem::size_of::<(usize, Arc)>(), + ); + let after = super::resident_bytes::btree_bytes( + state.pages.len().saturating_add(1), + std::mem::size_of::<(usize, Arc)>(), + ); + let bytes = bytes + .saturating_add(after.saturating_sub(before)) + .saturating_add(super::resident_bytes::ARC_HEADER_BYTES) + .saturating_add(std::mem::size_of::()); + state.memory.resize( + held.checked_add( + u64::try_from(bytes) + .map_err(|_| contract("resolution page window charge exceeds u64"))?, + ) + .ok_or_else(|| contract("resolution page window charge exceeds u64"))?, + )?; + let rows = Arc::new(rows); + state.pages.insert(page, Arc::clone(&rows)); + Ok(rows) + } + + pub(super) fn release(&self) -> Result<(), CodeIndexProductionErrorV1> { + let mut state = self + .state + .lock() + .map_err(|_| contract("resolution page window lock is poisoned"))?; + state.pages.clear(); + state.memory.resize(0) + } +} + +pub(super) struct SparseTargetEvidenceV1 { + rows: BTreeMap, + memory: super::ConstructionMemoryV1, } impl<'r> SparseSymbolsByNameV1<'r> { pub(super) fn new( index: &'r ResolutionIndexReaderV1<'r>, index_of_path: &'r HashMap<&'r str, usize>, + window: &'r SparseDefinitionWindowV1<'r>, edited: impl Iterator)>, - failure: &'r DecodeFailureV1, - ) -> Self { - let mut by_page: HashMap> = HashMap::new(); + construction_operation: &ResidentMemoryOperationV1, + ) -> Result { + let mut by_page = HashMap::>::new(); let mut edited_paths = HashSet::new(); for (file_index, path, file) in edited { edited_paths.insert(path); @@ -284,72 +391,131 @@ impl<'r> SparseSymbolsByNameV1<'r> { .push((file_index, Arc::clone(symbol))); } } - Self { - pages: (0..index.pages()).map(|_| OnceLock::new()).collect(), + let bytes = super::resident_bytes::hash_table_bytes::<(usize, Vec)>( + by_page.capacity(), + ) + .saturating_add( + by_page + .values() + .map(|rows| { + rows.capacity() + .saturating_mul(std::mem::size_of::()) + }) + .sum::(), + ) + .saturating_add(super::resident_bytes::hash_table_bytes::<&str>( + edited_paths.capacity(), + )); + let mut memory = super::ConstructionMemoryV1::new(construction_operation.clone())?; + memory.resize( + u64::try_from(bytes).map_err(|_| contract("resolution name index exceeds u64"))?, + )?; + Ok(Self { index, + window, index_of_path, edited: by_page, edited_paths, - failure, - } + targets: std::sync::Mutex::new(SparseTargetEvidenceV1 { + rows: BTreeMap::new(), + memory, + }), + }) } - fn load_page(&self, page: usize) -> HashMap> { - let mut by_name: HashMap> = HashMap::new(); - match self.index.definition_page(page) { - Ok(rows) => { - for (name, files) in rows { - for (path, symbols) in files { - if self.edited_paths.contains(path.as_str()) { - continue; - } - let Some(file_index) = self.index_of_path.get(path.as_str()) else { - self.failure.record(contract( - "sealed resolution index names a file outside its successor", - )); - continue; - }; - by_name.entry(name.clone()).or_default().extend( - symbols - .into_iter() - .map(|symbol| (*file_index, Arc::new(symbol))), - ); - } - } - } - Err(error) => self.failure.record(error), - } - for (file_index, symbol) in self.edited.get(&page).into_iter().flatten() { - by_name - .entry(symbol.simple_name.clone()) - .or_default() - .push((*file_index, Arc::clone(symbol))); - } - for symbols in by_name.values_mut() { - symbols.sort_by(|left, right| { - (left.0, &left.1.occurrence).cmp(&(right.0, &right.1.occurrence)) - }); - } - by_name + pub(super) fn take_targets( + &self, + ) -> Result { + let mut targets = self + .targets + .lock() + .map_err(|_| contract("resolution target evidence lock is poisoned"))?; + let empty = SparseTargetEvidenceV1 { + rows: BTreeMap::new(), + memory: super::ConstructionMemoryV1::new(targets.memory.operation.clone())?, + }; + Ok(std::mem::replace(&mut *targets, empty)) } +} - /// Every symbol a lookup has read, by occurrence. - fn read_symbols(&self) -> impl Iterator { - self.pages - .iter() - .filter_map(OnceLock::get) - .flat_map(HashMap::values) - .flatten() +impl SparseTargetEvidenceV1 { + fn insert( + &mut self, + index: usize, + symbol: &LineageSymbolRecordV1, + ) -> Result<(), CodeIndexProductionErrorV1> { + if self.rows.contains_key(&symbol.occurrence) { + return Ok(()); + } + let before = super::resident_bytes::btree_bytes( + self.rows.len(), + std::mem::size_of::<(SymbolOccurrenceId, (usize, SymbolIdentityDigest))>(), + ); + let after = super::resident_bytes::btree_bytes( + self.rows.len().saturating_add(1), + std::mem::size_of::<(SymbolOccurrenceId, (usize, SymbolIdentityDigest))>(), + ); + let additional = after + .saturating_sub(before) + .saturating_add(symbol.occurrence.as_str().len()) + .saturating_add(symbol.identity.as_str().len()); + let bytes = self + .memory + .charged_bytes + .checked_add( + u64::try_from(additional) + .map_err(|_| contract("resolution target evidence exceeds u64"))?, + ) + .ok_or_else(|| contract("resolution target evidence exceeds u64"))?; + self.memory.resize(bytes)?; + self.rows + .insert(symbol.occurrence.clone(), (index, symbol.identity.clone())); + Ok(()) + } + fn get(&self, occurrence: &SymbolOccurrenceId) -> Option<&(usize, SymbolIdentityDigest)> { + self.rows.get(occurrence) } } impl SymbolsByNameV1 for SparseSymbolsByNameV1<'_> { - fn get(&self, name: &str) -> Option<&[NamedSymbolV1]> { + fn get(&self, name: &str) -> Result>, CodeIndexProductionErrorV1> { let page = self.index.page_of(name); - self.pages[page] - .get_or_init(|| self.load_page(page)) - .get(name) - .map(Vec::as_slice) + let rows = self.window.page(page)?; + let mut result = Vec::new(); + if let Some(files) = rows.get(name) { + for (path, symbols) in files { + if self.edited_paths.contains(path.as_str()) { + continue; + } + let file_index = self.index_of_path.get(path.as_str()).ok_or_else(|| { + contract("sealed resolution index names a file outside its successor") + })?; + result.extend( + symbols + .iter() + .map(|symbol| (*file_index, Arc::clone(symbol))), + ); + } + } + result.extend( + self.edited + .get(&page) + .into_iter() + .flatten() + .filter(|(_, symbol)| symbol.simple_name == name) + .cloned(), + ); + result.sort_by(|left, right| { + (left.0, &left.1.occurrence).cmp(&(right.0, &right.1.occurrence)) + }); + let mut targets = self + .targets + .lock() + .map_err(|_| contract("resolution target evidence lock is poisoned"))?; + for (index, symbol) in &result { + targets.insert(*index, symbol)?; + } + Ok((!result.is_empty()).then_some(result)) } } @@ -363,11 +529,71 @@ pub(super) struct ResolvedFileV1 { pub(super) unresolved_calls: Vec, } +fn retain_resolution_growth( + memory: &mut super::ConstructionMemoryV1, + change: ResolutionMemoryChangeV1, +) -> Result<(), CodeIndexProductionErrorV1> { + memory.resize(change.apply_to_u64(memory.charged_bytes)?) +} + +fn resolved_file_entry<'a>( + files: &'a mut BTreeMap, + index: usize, + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result<&'a mut ResolvedFileV1, CodeIndexProductionErrorV1> { + if !files.contains_key(&index) { + let entry_bytes = std::mem::size_of::<(usize, ResolvedFileV1)>(); + let before = if files.is_empty() { + 0 + } else { + super::resident_bytes::btree_bytes(files.len(), entry_bytes) + }; + charge(ResolutionMemoryChangeV1::Retain( + super::resident_bytes::btree_bytes(files.len().saturating_add(1), entry_bytes) + .saturating_sub(before), + ))?; + } + Ok(files.entry(index).or_default()) +} + +impl ResolvedFileV1 { + fn retained_bytes(&self) -> usize { + super::resident_bytes::vec_bytes( + &self.edges, + self.edges.capacity(), + |(edge, path, identity)| { + super::resident_bytes::edge_heap_bytes(edge) + .saturating_add(path.len()) + .saturating_add(identity.as_str().len()) + }, + ) + .saturating_add(super::resident_bytes::vec_bytes( + &self.unresolved_calls, + self.unresolved_calls.capacity(), + super::resident_bytes::unresolved_heap_bytes, + )) + } +} + /// The edit's resolution: every successor file whose evidence it re-decided. pub(super) struct SparseResolutionV1 { pub(super) files: BTreeMap, pub(super) ambiguous_before: u64, pub(super) ambiguous_after: u64, + pub(super) output_memory: super::ConstructionMemoryV1, +} + +/// Consume each sorted selection once, borrowing the current file window. +fn selection_window<'a>( + selection: &'a super::helpers::ReferenceSelectionV1, + cursor: &mut usize, + last: usize, +) -> &'a super::helpers::ReferenceSelectionV1 { + let start = *cursor; + while *cursor < selection.len() && selection[*cursor].0 <= last { + *cursor += 1; + } + &selection[start..*cursor] } /// Re-decide the sites the edited files can move. `files` is the successor's @@ -383,6 +609,7 @@ pub(super) fn resolve_edit( by_name: &SparseSymbolsByNameV1<'_>, before_files: &[SparseFileV1<'_>], before_by_name: &SparseSymbolsByNameV1<'_>, + window: &SparseDefinitionWindowV1<'_>, edited: &[( usize, Arc, @@ -434,13 +661,13 @@ pub(super) fn resolve_edit( collect_bounded_ordered( &candidates.iter().copied().collect::>(), |&file_index| { - files[file_index].artifacts(); + files[file_index].binding()?; Ok::<_, CodeIndexProductionErrorV1>(()) }, )?; let mut selection = Vec::new(); for file_index in candidates { - let references = &files[file_index].as_ref().artifacts.unresolved_references; + let references = files[file_index].binding()?.unresolved_references(); let picks = if edited_indices.contains(&file_index) { (0..references.len()).collect::>() } else { @@ -482,7 +709,7 @@ pub(super) fn resolve_edit( .file_evidence(*parent_key)? .is_some_and(|evidence| evidence.targets_any(&edited_paths)); if repoints { - files[file_index].artifacts(); + files[file_index].binding()?; } Ok::<_, CodeIndexProductionErrorV1>(repoints.then_some(file_index)) }, @@ -490,7 +717,8 @@ pub(super) fn resolve_edit( .into_iter() .flatten() .collect::>(); - let moved = selected_references(files, Some(&selection)) + let moved = selected_references(files, Some(&selection))? + .into_iter() .map(|(_, reference)| site(reference)) .chain( edited @@ -498,51 +726,139 @@ pub(super) fn resolve_edit( .flat_map(|(_, before, _)| before.artifacts.unresolved_references.iter().map(site)), ) .collect::>(); - let ambiguous_before = if parent.statistics().ambiguous_name_drops.is_some() { - let mut before_selection = selection - .iter() - .filter(|(file_index, _)| !edited_indices.contains(file_index)) - .cloned() - .collect::>(); - before_selection.extend(edited.iter().map(|(file_index, before, _)| { + let mut before_selection = Vec::new(); + if parent.statistics().ambiguous_name_drops.is_some() { + before_selection.extend( + selection + .iter() + .filter(|(index, _)| !edited_indices.contains(index)) + .cloned(), + ); + before_selection.extend(edited.iter().map(|(index, before, _)| { ( - *file_index, + *index, (0..before.artifacts.unresolved_references.len()).collect(), ) })); - before_selection.sort_by_key(|(file_index, _)| *file_index); - resolve_selected_cross_file_references(before_files, before_by_name, &before_selection)? - .ambiguous_name_drops - } else { - 0 + before_selection.sort_by_key(|(index, _)| *index); + } + let units = selection + .iter() + .chain(&before_selection) + .map(|(index, _)| *index) + .collect::>() + .into_iter() + .collect::>(); + let mut before_modules = ResolutionModulesV1::new(before_files)?; + let mut modules = ResolutionModulesV1::new(files)?; + let mut output_memory = super::ConstructionMemoryV1::new(window.operation.clone())?; + let mut resolved = CrossFileResolutionV1 { + edges: Vec::new(), + gaps: Vec::new(), + ambiguous_name_drops: 0, }; - let resolved = resolve_selected_cross_file_references(files, by_name, &selection)?; + let mut import_unresolved = Vec::new(); + let mut ambiguous_before = 0_u64; + let mut before_cursor = 0; + let mut after_cursor = 0; + for units in units.chunks(crate::parallelism::indexing_workers().max(1)) { + let last = units[units.len() - 1]; + let before = selection_window(&before_selection, &mut before_cursor, last); + let after = selection_window(&selection, &mut after_cursor, last); + let base = output_memory.charged_bytes; + if !before.is_empty() { + ambiguous_before = ambiguous_before.saturating_add( + resolve_selected_with_modules( + before_files, + before_by_name, + &before_modules, + before, + &mut |change| retain_resolution_growth(&mut output_memory, change), + )? + .ambiguous_name_drops, + ); + for (index, _) in before { + before_modules.release_file_memo(*index); + } + output_memory.resize(base)?; + } + let mut charge = |change| retain_resolution_growth(&mut output_memory, change); + let result = resolve_selected_with_modules(files, by_name, &modules, after, &mut charge)?; + super::resolution_view::reserve_resolution_vec( + &mut resolved.edges, + result.edges.len(), + &mut charge, + )?; + super::resolution_view::reserve_resolution_vec( + &mut resolved.gaps, + result.gaps.len(), + &mut charge, + )?; + resolved.edges.extend(result.edges); + resolved.gaps.extend(result.gaps); + resolved.ambiguous_name_drops = resolved + .ambiguous_name_drops + .saturating_add(result.ambiguous_name_drops); + let imports = unresolved_import_calls_with_modules( + files, + by_name, + &modules, + Some(after), + &mut charge, + )?; + super::resolution_view::reserve_resolution_vec( + &mut import_unresolved, + imports.len(), + &mut charge, + )?; + import_unresolved.extend(imports); + for (index, _) in after { + modules.release_file_memo(*index); + } + window.release()?; + let held = super::resident_bytes::vec_bytes( + &resolved.edges, + resolved.edges.capacity(), + super::resident_bytes::edge_heap_bytes, + ) + .saturating_add(super::resident_bytes::vec_bytes( + &resolved.gaps, + resolved.gaps.capacity(), + super::resident_bytes::unresolved_heap_bytes, + )) + .saturating_add(super::resident_bytes::vec_bytes( + &import_unresolved, + import_unresolved.capacity(), + super::resident_bytes::unresolved_heap_bytes, + )); + output_memory.resize( + u64::try_from(held).map_err(|_| contract("sparse resolution charge exceeds u64"))?, + )?; + } + resolved.edges.sort_unstable_by(edge_order); + resolved.edges.dedup(); let ambiguous_after = resolved.ambiguous_name_drops; // A resolved edge's target is a symbol some lookup read: a page row, or a // symbol of a file resolution decoded. - let mut targets = HashMap::<&SymbolOccurrenceId, (usize, &SymbolIdentityDigest)>::new(); - for (file_index, symbol) in by_name.read_symbols() { - targets.insert(&symbol.occurrence, (*file_index, &symbol.identity)); - } + let mut targets = by_name.take_targets()?; for (file_index, file) in files.iter().enumerate() { if let Some(file) = file.loaded() { - for symbol in &file.artifacts.symbols { - targets.insert(&symbol.occurrence, (file_index, &symbol.identity)); + for symbol in file.symbols() { + targets.insert(file_index, symbol)?; } } } - let owner_of = selection - .iter() - .flat_map(|(file_index, _)| { - files[*file_index] - .as_ref() - .artifacts - .symbols - .iter() - .map(move |symbol| (&symbol.occurrence, *file_index)) - }) - .collect::>(); + let mut charge = |bytes| retain_resolution_growth(&mut output_memory, bytes); + let mut owner_of = HashMap::new(); + for (file_index, _) in &selection { + for symbol in files[*file_index].binding()?.symbols() { + if !owner_of.contains_key(&symbol.occurrence) { + super::resolution_view::reserve_resolution_map(&mut owner_of, &mut charge)?; + } + owner_of.insert(&symbol.occurrence, *file_index); + } + } let mut result = BTreeMap::::new(); for edge in resolved.edges { let owner = owner_of @@ -551,92 +867,165 @@ pub(super) fn resolve_edit( let (target_index, identity) = targets .get(&edge.to_occurrence) .ok_or_else(|| contract("a re-resolved edge targets a symbol no lookup read"))?; - let path = files[*target_index].logical_path().to_owned(); - let identity = (*identity).clone(); - result - .entry(*owner) - .or_default() - .edges - .push((edge, path, identity)); + let path = files[*target_index].logical_path(); + let entry = resolved_file_entry(&mut result, *owner, &mut charge)?; + super::resolution_view::reserve_resolution_vec(&mut entry.edges, 1, &mut charge)?; + charge(ResolutionMemoryChangeV1::Retain( + path.len().saturating_add(identity.as_str().len()), + ))?; + entry.edges.push((edge, path.to_owned(), identity.clone())); } for file_index in selection .iter() .map(|(file_index, _)| file_index) .chain(&dependents) { - let entry = result.entry(*file_index).or_default(); + let entry = resolved_file_entry(&mut result, *file_index, &mut charge)?; if edited_indices.contains(file_index) { continue; } - let file = files[*file_index].as_ref(); + let file = files[*file_index].binding()?; let parent_key = parent_key_of_path .get(files[*file_index].logical_path()) .ok_or_else(|| contract("a carried file has no parent segment"))?; let Some(evidence) = parent.file_evidence(*parent_key)? else { continue; }; - for sealed in evidence.sealed_edges(file)? { - if moved.contains(&(&sealed.from_occurrence, sealed.evidence_span)) { - continue; - } - let target_file = occurrence_of_path - .get(sealed.target_path.as_str()) - .ok_or_else(|| { + evidence.for_each_sealed_edge( + file, + &window.operation, + &mut |from, kind, evidence_span, target_path, target_identity| { + if moved.contains(&(from, evidence_span)) { + return Ok(()); + } + let target_file = occurrence_of_path.get(target_path).ok_or_else(|| { contract("sealed cross-file edge targets a file outside its successor") })?; - let to_occurrence = - crate::chunks::symbol_occurrence_id(target_file, &sealed.target_identity) - .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - entry.edges.push(( - CanonicalRelationEdgeV1 { - from_occurrence: sealed.from_occurrence, - to_occurrence, - kind: sealed.kind, - authority: tracedecay_domain::EdgeAuthorityV1::NameResolved, - evidence_span: sealed.evidence_span, - }, - sealed.target_path, - sealed.target_identity, - )); - } - for call in evidence.unresolved_calls(file)? { + super::resolution_view::reserve_resolution_vec(&mut entry.edges, 1, &mut charge)?; + // The source and rebound target share the canonical symbol + // occurrence digest domain, hence the same wire length. + charge(ResolutionMemoryChangeV1::Retain( + super::resident_bytes::edge_endpoint_heap_bytes(from, from) + .saturating_add(target_path.len()) + .saturating_add(target_identity.as_str().len()), + ))?; + let to_occurrence = + crate::chunks::symbol_occurrence_id(target_file, target_identity) + .map_err(|error| contract(&error.to_string()))?; + entry.edges.push(( + CanonicalRelationEdgeV1 { + from_occurrence: from.clone(), + to_occurrence, + kind, + authority: tracedecay_domain::EdgeAuthorityV1::NameResolved, + evidence_span, + }, + target_path.to_owned(), + target_identity.clone(), + )); + Ok(()) + }, + )?; + evidence.for_each_unresolved_call(file, &window.operation, &mut |call| { if !moved.contains(&site(call)) { + super::resolution_view::reserve_resolution_vec( + &mut entry.unresolved_calls, + 1, + &mut charge, + )?; + charge(ResolutionMemoryChangeV1::Retain( + super::resident_bytes::unresolved_heap_bytes(call), + ))?; entry.unresolved_calls.push(call.clone()); } - } + Ok(()) + })?; } for entry in result.values_mut() { entry .edges - .sort_by(|left, right| edge_order(&left.0, &right.0)); + .sort_unstable_by(|left, right| edge_order(&left.0, &right.0)); entry.edges.dedup_by(|left, right| left.0 == right.0); } // A site's limitations depend only on its own references and the edges // bound at it, and the selection carries every reference of each site. - let references = selected_references(files, Some(&selection)) + let reference_count = selection + .iter() + .map(|(_, picks)| picks.len()) + .sum::(); + charge(ResolutionMemoryChangeV1::Retain( + reference_count.saturating_mul( + std::mem::size_of::<(usize, &CodeIndexUnresolvedReferenceV1)>().saturating_mul(2) + + std::mem::size_of::<(&str, &CodeIndexUnresolvedReferenceV1)>(), + ), + ))?; + let references = selected_references(files, Some(&selection))? + .into_iter() .map(|(file_index, reference)| (files[file_index].logical_path(), reference)) .collect::>(); - let sites = references - .iter() - .map(|(_, reference)| site(reference)) - .collect::>(); + let mut sites = HashSet::new(); + for (_, reference) in &references { + if !sites.contains(&site(reference)) { + super::resolution_view::reserve_resolution_set(&mut sites, &mut charge)?; + sites.insert(site(reference)); + } + } let at_sites = |edge: &&CanonicalRelationEdgeV1| { sites.contains(&(&edge.from_occurrence, edge.evidence_span)) }; - let file_edges = selection - .iter() - .flat_map(|(file_index, _)| files[*file_index].as_ref().artifacts.edges.iter()) - .filter(at_sites); - let cross_file = result - .values() - .flat_map(|entry| entry.edges.iter().map(|(edge, _, _)| edge)) - .filter(at_sites) - .cloned() - .collect::>(); + let mut file_edges = Vec::new(); + for (file_index, _) in &selection { + for edge in files[*file_index] + .binding()? + .edges() + .iter() + .filter(at_sites) + { + super::resolution_view::reserve_resolution_vec(&mut file_edges, 1, &mut charge)?; + file_edges.push(edge); + } + } + let cross_file = || { + result + .values() + .flat_map(|entry| entry.edges.iter().map(|(edge, _, _)| edge)) + .filter(at_sites) + }; + let edge_count = file_edges.len().saturating_add(cross_file().count()); + // Admit the canonical projector's site maps and worst-case cloned call + // rows before it builds them. Import rows move without cloning their heaps. + charge(ResolutionMemoryChangeV1::Retain( + super::resident_bytes::btree_bytes( + references.len(), + std::mem::size_of::<((&SymbolOccurrenceId, SourceSpan), Option<&str>)>(), + ) + .saturating_add(super::resident_bytes::btree_bytes( + edge_count, + std::mem::size_of::<((&SymbolOccurrenceId, SourceSpan), &str)>(), + )) + .saturating_add(super::resident_bytes::btree_bytes( + edge_count, + std::mem::size_of::<(&SymbolOccurrenceId, SourceSpan)>(), + )) + .saturating_add( + references + .len() + .saturating_add(import_unresolved.len()) + .saturating_mul(2) + .max(4) + .saturating_mul(std::mem::size_of::()), + ) + .saturating_add( + references + .iter() + .map(|(_, reference)| super::resident_bytes::unresolved_heap_bytes(reference)) + .sum::(), + ), + ))?; let rederived = unresolved_call_limitations( &references, - file_edges.chain(cross_file.iter()), - unresolved_import_calls(files, by_name, Some(&selection)), + file_edges.iter().copied().chain(cross_file()), + import_unresolved, &|| Ok(()), ) .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; @@ -644,19 +1033,78 @@ pub(super) fn resolve_edit( let owner = owner_of .get(&call.from_occurrence) .ok_or_else(|| contract("a re-derived call limitation leaves the selection"))?; - result - .entry(*owner) - .or_default() - .unresolved_calls - .push(call); + let entry = resolved_file_entry(&mut result, *owner, &mut charge)?; + super::resolution_view::reserve_resolution_vec( + &mut entry.unresolved_calls, + 1, + &mut charge, + )?; + entry.unresolved_calls.push(call); } for entry in result.values_mut() { - entry.unresolved_calls.sort(); + entry.unresolved_calls.sort_unstable(); entry.unresolved_calls.dedup(); } + drop(charge); + drop(file_edges); + drop(sites); + drop(references); + drop(owner_of); + drop(targets); + let held = if result.is_empty() { + 0 + } else { + super::resident_bytes::btree_bytes( + result.len(), + std::mem::size_of::<(usize, ResolvedFileV1)>(), + ) + .saturating_add( + result + .values() + .map(ResolvedFileV1::retained_bytes) + .sum::(), + ) + }; + output_memory.resize( + u64::try_from(held).map_err(|_| contract("sparse resolution charge exceeds u64"))?, + )?; Ok(SparseResolutionV1 { files: result, ambiguous_before, ambiguous_after, + output_memory, }) } + +#[cfg(test)] +mod tests { + use super::selection_window; + + #[test] + fn sorted_resolution_windows_borrow_each_selected_file_once() { + let before = vec![(1, vec![2, 4]), (3, vec![0]), (9, vec![1])]; + let after = vec![(0, vec![1]), (3, vec![2]), (8, vec![0, 1])]; + let mut before_cursor = 0; + let mut after_cursor = 0; + let mut visited_before = Vec::new(); + let mut visited_after = Vec::new(); + for last in [1, 8, 9] { + let before_window = selection_window(&before, &mut before_cursor, last); + let after_window = selection_window(&after, &mut after_cursor, last); + for (index, picks) in before_window { + let original = before.iter().find(|(file, _)| file == index).unwrap(); + assert!(std::ptr::eq(picks.as_slice(), original.1.as_slice())); + visited_before.push(*index); + } + for (index, picks) in after_window { + let original = after.iter().find(|(file, _)| file == index).unwrap(); + assert!(std::ptr::eq(picks.as_slice(), original.1.as_slice())); + visited_after.push(*index); + } + } + assert_eq!(visited_before, vec![1, 3, 9]); + assert_eq!(visited_after, vec![0, 3, 8]); + assert!(selection_window(&before, &mut before_cursor, usize::MAX).is_empty()); + assert!(selection_window(&after, &mut after_cursor, usize::MAX).is_empty()); + } +} diff --git a/crates/tracedecay-code-index/src/production/sparse_successor.rs b/crates/tracedecay-code-index/src/production/sparse_successor.rs index 6f9d8d708f..b4c827e4b1 100644 --- a/crates/tracedecay-code-index/src/production/sparse_successor.rs +++ b/crates/tracedecay-code-index/src/production/sparse_successor.rs @@ -8,6 +8,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; +use tracedecay_runtime_core::resident_memory::ResidentMemoryOperationV1; use tracedecay_domain::{ CanonicalRelationEdgeV1, CodeGenerationId, CodeGenerationManifestV1, FileOccurrenceId, @@ -25,12 +26,14 @@ use super::partitioned_codec::{ PartitionedFileSegmentDescriptorV1, SealedGenerationSegmentPublicationV1, encode_file_evidence_segment, encode_file_segment, publish_code_graph_page, snapshot_file_keys, }; +use super::resolution_view::ResolutionFileFactsV1; +use super::resolution_view::ResolutionMemoryChangeV1; use super::sealed_codec::FileScopeIdentityV1; -use super::sealed_parent::{ - DecodeFailureV1, SealedParentGenerationV1, SparseFileSourceV1, SparseFileV1, -}; +use super::sealed_parent::{SealedParentGenerationV1, SparseFileSourceV1, SparseFileV1}; use super::sparse_increment::{EditedFileV1, contract, count}; -use super::sparse_resolution::{ResolvedFileV1, SparseSymbolsByNameV1, resolve_edit}; +use super::sparse_resolution::{ + ResolvedFileV1, SparseDefinitionWindowV1, SparseSymbolsByNameV1, resolve_edit, +}; use super::{CodeIndexProductionErrorV1, FileGenerationArtifactsV1}; use crate::lineage::SymbolLineageCandidateV1; @@ -54,7 +57,6 @@ pub(super) struct SealedSuccessorV1<'p> { occurrence_of_path: HashMap<&'p str, &'p FileOccurrenceId>, parent_key_of_path: HashMap<&'p str, u32>, edited_index: HashMap<&'p str, usize>, - failure: DecodeFailureV1, } impl<'p> SealedSuccessorV1<'p> { @@ -113,7 +115,6 @@ impl<'p> SealedSuccessorV1<'p> { occurrence_of_path, parent_key_of_path, edited_index, - failure: DecodeFailureV1::default(), }) } @@ -134,6 +135,7 @@ impl<'p> SealedSuccessorV1<'p> { parent: &'v SealedParentGenerationV1, edited: &'v [EditedFileV1<'p>], before: bool, + construction_operation: &ResidentMemoryOperationV1, ) -> Result>, CodeIndexProductionErrorV1> { let edited_by_path = edited .iter() @@ -144,10 +146,6 @@ impl<'p> SealedSuccessorV1<'p> { ) }) .collect::>(); - let stand_in = edited - .first() - .map(|file| &file.after) - .ok_or_else(|| contract("a sparse resolution view needs an edited file"))?; self.present .iter() .map(|file| { @@ -157,9 +155,12 @@ impl<'p> SealedSuccessorV1<'p> { .map(|language| language.as_str()) .ok_or_else(|| contract("present snapshot file has no declared language"))?; Ok(match edited_by_path.get(file.logical_path.as_str()) { - Some(after) => { - SparseFileV1::decoded(&file.logical_path, language, Arc::clone(after)) - } + Some(after) => SparseFileV1::decoded( + &file.logical_path, + language, + Arc::clone(after), + construction_operation.clone(), + )?, None => SparseFileV1::carried( &file.logical_path, language, @@ -171,8 +172,7 @@ impl<'p> SealedSuccessorV1<'p> { file_occurrence_id: &file.file_occurrence_id, generation_id: self.generation_id, snapshot_digest: self.snapshot_digest, - stand_in, - failure: &self.failure, + construction_operation: construction_operation.clone(), }, ), }) @@ -186,22 +186,26 @@ impl<'p> SealedSuccessorV1<'p> { &self, parent: &SealedParentGenerationV1, edited: &[EditedFileV1<'p>], + construction_operation: &ResidentMemoryOperationV1, ) -> Result { if edited.is_empty() { return Ok(SuccessorResolutionV1::default()); } - let view = self.view(parent, edited, false)?; - let before_view = self.view(parent, edited, true)?; + let view = self.view(parent, edited, false, construction_operation)?; + let before_view = self.view(parent, edited, true, construction_operation)?; let index = parent.resolution_index()?; + let window = SparseDefinitionWindowV1::new(&index, construction_operation)?; let before_by_name = SparseSymbolsByNameV1::new( &index, &self.index_of_path, + &window, std::iter::empty(), - &self.failure, - ); + construction_operation, + )?; let by_name = SparseSymbolsByNameV1::new( &index, &self.index_of_path, + &window, edited.iter().map(|file| { ( self.edited_index[file.file.logical_path.as_str()], @@ -209,8 +213,8 @@ impl<'p> SealedSuccessorV1<'p> { &file.after, ) }), - &self.failure, - ); + construction_operation, + )?; let pairs = edited .iter() .map(|file| { @@ -228,29 +232,18 @@ impl<'p> SealedSuccessorV1<'p> { &by_name, &before_view, &before_by_name, + &window, &pairs, &self.index_of_path, &self.occurrence_of_path, &self.parent_key_of_path, ); - // A stand-in for an undecodable file can trip a contract inside the - // resolution; the decode failure is the cause and wins. - if let Some(error) = self.failure.take() { - return Err(error); - } let resolution = resolution?; - let mut files = BTreeMap::new(); - for (file_index, resolved) in resolution.files { - let file = self.present[file_index]; - files.insert( - file.logical_path.clone(), - (Arc::clone(view[file_index].artifacts()), resolved), - ); - } Ok(SuccessorResolutionV1 { - files, + files: resolution.files, ambiguous_before: resolution.ambiguous_before, ambiguous_after: resolution.ambiguous_after, + _output_memory: Some(resolution.output_memory), }) } @@ -363,7 +356,10 @@ impl<'p> SealedSuccessorV1<'p> { .copied() .ok_or_else(|| contract("a successor file has no parent segment"))?; let parent_descriptor = parent.file_evidence_descriptor(parent_key); - let resolved = resolution.files.get(&file.logical_path); + let resolved = self + .index_of_path + .get(file.logical_path.as_str()) + .and_then(|index| resolution.files.get(index)); let edited_file = edited_by_path.get(file.logical_path.as_str()); let evidence = if resolved.is_some() || edited_file.is_some() { if let Some(parent_evidence) = parent.file_evidence(parent_key)? { @@ -373,10 +369,17 @@ impl<'p> SealedSuccessorV1<'p> { } let artifacts = match (resolved, edited_file) { (_, Some(edited)) => Arc::clone(&edited.after), - (Some((artifacts, _)), None) => Arc::clone(artifacts), + (Some(_), None) => parent.decode_file( + parent + .file_segment(&file.logical_path) + .ok_or_else(|| contract("a re-decided file has no parent segment"))?, + &file.file_occurrence_id, + self.generation_id, + self.snapshot_digest, + )?, (None, None) => return Err(contract("a re-decided file has no artifacts")), }; - let edges = resolved.map_or_default(|(_, resolved)| { + let edges = resolved.map_or_default(|resolved| { resolved .edges .iter() @@ -384,7 +387,7 @@ impl<'p> SealedSuccessorV1<'p> { .collect::>() }); counts.after = counts.after.saturating_add(count(edges.len())?); - let calls = resolved.map_or_default(|(_, resolved)| { + let calls = resolved.map_or_default(|resolved| { resolved.unresolved_calls.iter().collect::>() }); let identity; @@ -476,7 +479,10 @@ impl<'p> SealedSuccessorV1<'p> { let key = u32::try_from(key).map_err(|_| contract("code graph page file key exceeds u32"))?; let path = file.logical_path.as_str(); - let resolved = resolution.files.get(&file.logical_path); + let resolved = self + .index_of_path + .get(file.logical_path.as_str()) + .and_then(|index| resolution.files.get(index)); let edited_file = edited_by_path.get(path); let row_unchanged = parent_rows.get(path) == Some(&file); if resolved.is_none() && edited_file.is_none() && row_unchanged { @@ -490,7 +496,16 @@ impl<'p> SealedSuccessorV1<'p> { } let artifacts = match (edited_file, resolved) { (Some(after), _) => Some(Arc::clone(after)), - (None, Some((artifacts, _))) => Some(Arc::clone(artifacts)), + (None, Some(_)) => Some( + parent.decode_file( + parent + .file_segment(&file.logical_path) + .ok_or_else(|| contract("a re-decided file has no parent segment"))?, + &file.file_occurrence_id, + self.generation_id, + self.snapshot_digest, + )?, + ), (None, None) if file.disposition == SnapshotFileDispositionV1::Present => { return Err(contract( "a present file's graph page changed without its artifacts", @@ -500,14 +515,13 @@ impl<'p> SealedSuccessorV1<'p> { }; let inputs = match artifacts.as_deref() { Some(artifacts) => { - let cross_file = - resolved.map_or_default(|(_, resolved)| resolved.edges.as_slice()); + let cross_file = resolved.map_or_default(|resolved| resolved.edges.as_slice()); self.page_inputs( key, file, artifacts, cross_file, - resolved.map_or_default(|(_, resolved)| resolved.unresolved_calls.clone()), + resolved.map_or_default(|resolved| resolved.unresolved_calls.clone()), )? } None => CodeGraphPageInputsV1 { @@ -571,11 +585,542 @@ impl<'p> SealedSuccessorV1<'p> { } } -/// The files whose cross-file evidence the edit re-decided, by logical path, -/// with the artifacts resolution read them as. +/// The files whose cross-file evidence the edit re-decided, by present-file +/// index. The reservation outlives every retained row through sealing. #[derive(Default)] pub(super) struct SuccessorResolutionV1 { - pub(super) files: BTreeMap, ResolvedFileV1)>, + pub(super) files: BTreeMap, pub(super) ambiguous_before: u64, pub(super) ambiguous_after: u64, + // Declared last so rows are destroyed before their reservation is released. + pub(super) _output_memory: Option, +} + +/// Canonical sealed outputs of cold resolution. No decoded file, symbol index, +/// request, or projection receipt escapes this operation. +pub(super) struct StagedResolutionPartsV1 { + pub(super) file_evidence: Vec, + pub(super) code_graph_pages: Vec, + pub(super) resolution_index: super::resolution_index::PartitionedResolutionIndexDescriptorV1, + pub(super) coverage: tracedecay_domain::CoverageSummaryV1, + pub(super) statistics: super::CodeIndexGenerationStatisticsV1, +} + +/// The canonical retained representation at a resolution boundary. This is +/// emitted only at phase completion or failure, never once per file. +fn trace_cold_resolution_facts( + facts: &[super::resolution_view::CompactFileResolutionV1], + retained: usize, + ordered_bytes: usize, + completed: bool, +) { + tracing::trace!( + phase = "facts", + completed, + loaded_files = facts.len(), + retained_facts_bytes = retained, + ordered_bytes, + symbol_count = facts.iter().map(|file| file.symbols().len()).sum::(), + retained_symbol_records_bytes = facts + .iter() + .map(|file| file.owned_symbol_bytes()) + .sum::(), + retained_rows_bytes = facts + .iter() + .map(|file| file.retained_row_bytes()) + .sum::(), + graph_owner_count = facts + .iter() + .map(|file| file.graph_owners().len()) + .sum::(), + retained_graph_owner_bytes = facts + .iter() + .map(|file| file.graph_owner_bytes()) + .sum::(), + "cold resolution facts boundary" + ); +} + +/// Resolve one real cold generation from its already staged canonical files. +/// Only charged binding facts span files; decode and encoding pages are +/// released before the next file operation, and all facts drop before return. +#[allow(clippy::too_many_arguments)] +pub(super) fn resolve_staged_generation( + manifest: &CodeGenerationManifestV1, + snapshot: &SanitizedCodeSnapshotV1, + files: &[PartitionedFileSegmentDescriptorV1], + read: Arc, + control: &dyn super::CodeIndexExecutionControlV1, + retain_facts: &mut impl FnMut(u64) -> Result<(), CodeIndexProductionErrorV1>, + publish: &mut impl FnMut( + SealedGenerationSegmentPublicationV1<'_>, + ) -> Result<(), CodeIndexProductionErrorV1>, +) -> Result { + let facts_span = tracing::trace_span!("code_index.cold.facts", files = files.len()).entered(); + let source = super::sealed_parent::StagedFileSourceV1::new(manifest, snapshot, files, read)?; + // The full builder indexes files in occurrence order; retain that order + // even when snapshot keys have a different order. + let ordered_bytes = files + .len() + .checked_mul(std::mem::size_of::<&PartitionedFileSegmentDescriptorV1>()) + .ok_or_else(|| contract("resolution file order bytes overflowed"))?; + let mut retained = files + .len() + .checked_mul(std::mem::size_of::< + super::resolution_view::CompactFileResolutionV1, + >()) + .ok_or_else(|| contract("resolution facts vector bytes overflowed"))?; + let outer_bytes = retained + .checked_add(ordered_bytes) + .ok_or_else(|| contract("resolution outer vector bytes overflowed"))?; + let _ordered_layout = std::alloc::Layout::array::<&PartitionedFileSegmentDescriptorV1>( + files.len(), + ) + .map_err(|error| contract(&format!("resolution file order layout is invalid: {error}")))?; + let _facts_layout = + std::alloc::Layout::array::(files.len()) + .map_err(|error| contract(&format!("resolution facts layout is invalid: {error}")))?; + retain_facts(count(outer_bytes)?)?; + let mut ordered = files.iter().collect::>(); + ordered.sort_unstable_by(|left, right| left.file_occurrence_id.cmp(&right.file_occurrence_id)); + let mut facts = Vec::with_capacity(files.len()); + let mut coverage = super::helpers::coverage_summary(snapshot, &[]); + let mut source_total_bytes = 0_u64; + let mut symbol_count = 0_u64; + let facts_loaded = (|| -> Result<(), CodeIndexProductionErrorV1> { + for descriptor in &ordered { + super::lexical_page_source::checkpoint(control)?; + let file = source.decode_file(descriptor.file_key)?; + coverage = super::helpers::add_coverage(coverage, super::helpers::file_coverage(&file)); + let bytes = super::generation_statistics::checked_source_total(std::iter::once( + &file.extraction.coverage, + ))?; + source_total_bytes = source_total_bytes + .checked_add(bytes) + .ok_or_else(|| contract("generation source byte total overflowed"))?; + symbol_count = symbol_count + .checked_add(count(file.artifacts.symbols.len())?) + .ok_or_else(|| contract("generation symbol count exceeds u64"))?; + let source_bytes = super::resolution_view::resolution_source_bytes(&file)?; + let base = retained + .checked_add(ordered_bytes) + .and_then(|bytes| bytes.checked_add(source_bytes)) + .ok_or_else(|| contract("resolution source overlap exceeds usize"))?; + let mut admitted = 0_u64; + let compact = + super::resolution_view::CompactFileResolutionV1::from_file(&file, &mut |copies| { + // The outer facts Vec already owns the compact value's inline slot. + let copied_heap = copies + .checked_sub(std::mem::size_of::< + super::resolution_view::CompactFileResolutionV1, + >() as u64) + .ok_or_else(|| contract("resolution clone lacks its inline body"))?; + let overlap = count(base)? + .checked_add(copied_heap) + .ok_or_else(|| contract("resolution source/clone overlap exceeds u64"))?; + retain_facts(overlap).inspect_err(|_| { + tracing::trace!( + phase = "facts", + retained_facts_bytes = retained, + ordered_bytes, + current_source_bytes = source_bytes, + current_copy_bound_bytes = copied_heap, + requested_overlap_bytes = overlap, + "cold fact window admission refused" + ); + })?; + admitted = overlap; + Ok(()) + })?; + drop(file); + retained = retained + .checked_add( + compact + .retained_bytes() + .checked_sub(std::mem::size_of::< + super::resolution_view::CompactFileResolutionV1, + >()) + .ok_or_else(|| contract("resolution facts lack their inline body"))?, + ) + .ok_or_else(|| contract("resolution facts bytes overflowed"))?; + let settled = count( + retained + .checked_add(ordered_bytes) + .ok_or_else(|| contract("resolution facts/order bytes overflowed"))?, + )?; + if settled > admitted { + return Err(contract( + "resolution facts exceed their admitted source/clone bound", + )); + } + facts.push(compact); + retain_facts(settled)?; + } + Ok(()) + })(); + trace_cold_resolution_facts(&facts, retained, ordered_bytes, facts_loaded.is_ok()); + facts_loaded?; + drop(facts_span); + super::lexical_page_source::checkpoint(control)?; + let facts_bytes = retained.saturating_add( + ordered + .capacity() + .saturating_mul(std::mem::size_of::<&PartitionedFileSegmentDescriptorV1>()), + ); + let cross_span = tracing::trace_span!( + "code_index.cold.cross_resolution", + retained_facts_bytes = facts_bytes, + ) + .entered(); + tracing::trace!( + phase = "cross_resolution", + retained_facts_bytes = facts_bytes, + "cold resolution phase started" + ); + let resolved = super::helpers::resolve_cross_file_references_charged(&facts, &mut |bytes| { + retain_facts(count(facts_bytes)?.saturating_add(bytes)) + })?; + let super::helpers::CrossFileResolutionV1 { + mut edges, + gaps, + ambiguous_name_drops, + } = resolved; + let cross_count = edges.len(); + let own_count = facts.iter().map(|file| file.edges().len()).sum::(); + let mut outputs = super::resident_bytes::vec_bytes( + &edges, + edges.capacity(), + super::resident_bytes::edge_heap_bytes, + ) + .saturating_add(super::resident_bytes::vec_bytes( + &gaps, + gaps.capacity(), + super::resident_bytes::unresolved_heap_bytes, + )); + super::resolution_view::reserve_resolution_vec( + &mut edges, + own_count, + &mut |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut outputs, |outputs| { + retain_facts(count(facts_bytes.saturating_add(outputs))?) + }) + }, + )?; + for file in &mut facts { + edges.extend(file.take_edges()); + } + edges.rotate_left(cross_count); + retain_facts(count( + facts_bytes.saturating_add(outputs).saturating_add( + edges + .len() + .div_ceil(2) + .saturating_mul(std::mem::size_of::()), + ), + )?)?; + edges.sort_by(edge_order); + retained = facts + .iter() + .map(|file| file.retained_bytes()) + .sum::() + .saturating_add( + ordered + .capacity() + .saturating_mul(std::mem::size_of::<&PartitionedFileSegmentDescriptorV1>()), + ); + let edge_bytes = super::resident_bytes::vec_bytes( + &edges, + edges.capacity(), + super::resident_bytes::edge_heap_bytes, + ); + let gap_bytes = super::resident_bytes::vec_bytes( + &gaps, + gaps.capacity(), + super::resident_bytes::unresolved_heap_bytes, + ); + let mut unresolved = super::resolution_outputs::unresolved_calls_for_edges_charged( + &facts, + &edges, + &mut |bytes| { + retain_facts( + count( + retained + .saturating_add(edge_bytes) + .saturating_add(gap_bytes), + )? + .saturating_add(bytes), + ) + }, + )?; + let call_bytes = super::resident_bytes::vec_bytes( + &unresolved, + unresolved.capacity(), + super::resident_bytes::unresolved_heap_bytes, + ); + let mut live = retained + .saturating_add(edge_bytes) + .saturating_add(gap_bytes) + .saturating_add(call_bytes); + super::resolution_view::reserve_resolution_vec( + &mut unresolved, + gaps.len(), + &mut |bytes: ResolutionMemoryChangeV1| { + bytes.apply_with(&mut live, |live| retain_facts(count(live)?)) + }, + )?; + unresolved.extend(gaps); + retain_facts(count( + live.saturating_add( + unresolved + .len() + .div_ceil(2) + .saturating_mul(std::mem::size_of::< + crate::chunks::CodeIndexUnresolvedReferenceV1, + >()), + ), + )?)?; + unresolved.sort(); + unresolved.dedup(); + let output_bytes = super::resident_bytes::vec_bytes( + &unresolved, + unresolved.capacity(), + super::resident_bytes::unresolved_heap_bytes, + ) + .saturating_add(edge_bytes); + tracing::trace!( + phase = "cross_resolution", + retained_facts_bytes = retained, + edge_count = edges.len(), + unresolved_count = unresolved.len(), + output_bytes, + "cold resolution phase completed" + ); + drop(cross_span); + let evidence_span = tracing::trace_span!("code_index.cold.file_evidence").entered(); + let descriptor_bytes = ordered + .len() + .saturating_mul( + std::mem::size_of::() + "sha256:".len() + 64, + ) + .saturating_add( + ordered + .iter() + .map(|file| file.file_occurrence_id.as_str().len()) + .sum::(), + ); + let phase_base = retained + .saturating_add(output_bytes) + .saturating_add(descriptor_bytes); + retain_facts(count(phase_base)?)?; + tracing::trace!( + phase = "file_evidence", + retained_base_bytes = phase_base, + descriptor_bound_bytes = descriptor_bytes, + "cold resolution phase started" + ); + let mut file_evidence = Vec::with_capacity(ordered.len()); + super::file_evidence_rows::for_each_cold_file_evidence( + &facts, + &edges, + &unresolved, + &manifest.generation_id, + &mut |workspace| retain_facts(count(phase_base)?.saturating_add(workspace)), + &mut |index, evidence, workspace, retain| { + super::lexical_page_source::checkpoint(control)?; + if evidence.is_empty() { + return Ok(()); + } + let file = ordered[index]; + let (descriptor, bytes) = + super::partitioned_codec::encode_file_evidence_segment_charged( + file.file_key, + &file.file_occurrence_id, + evidence, + &mut |encoding| retain(workspace.saturating_add(encoding)), + )?; + publish(SealedGenerationSegmentPublicationV1::FileEvidence { + digest: &descriptor.segment_digest, + bytes: &bytes, + })?; + file_evidence.push(descriptor); + Ok(()) + }, + )?; + file_evidence.sort_by_key(|descriptor| descriptor.file_key); + tracing::trace!( + phase = "file_evidence", + descriptors = file_evidence.len(), + retained_base_bytes = phase_base, + "cold resolution phase completed" + ); + drop(evidence_span); + let index_span = tracing::trace_span!("code_index.cold.resolution_index").entered(); + tracing::trace!( + phase = "resolution_index", + retained_base_bytes = phase_base, + "cold resolution phase started" + ); + let resolution_index = super::resolution_index::seal_resolution_index_charged( + &facts, + &mut |workspace| retain_facts(count(phase_base)?.saturating_add(workspace)), + &mut *publish, + )?; + tracing::trace!( + phase = "resolution_index", + segments = resolution_index.segments().count(), + retained_base_bytes = phase_base, + "cold resolution phase completed" + ); + drop(index_span); + // Graph pages decode their own bounded file window. Keep only occurrence + // ownership here; symbol records and binding rows have no remaining reader. + let roster_body = facts + .len() + .checked_mul(std::mem::size_of::< + Vec, + >()) + .ok_or_else(|| contract("graph owner roster bytes overflowed"))?; + let roster_heaps = facts + .iter() + .map(|file| file.graph_owner_bytes()) + .sum::(); + retain_facts(count(phase_base.saturating_add(roster_body))?)?; + let mut graph_owners = Vec::with_capacity(facts.len()); + for file in facts { + graph_owners.push(file.into_graph_owners()); + } + let phase_base = phase_base + .checked_sub(retained) + .ok_or_else(|| contract("graph base lacks resolution facts"))? + .saturating_add(roster_body) + .saturating_add(roster_heaps) + .saturating_add(ordered_bytes); + retain_facts(count(phase_base)?)?; + let graph_span = tracing::trace_span!("code_index.cold.graph").entered(); + let phase_base = phase_base.saturating_add( + resolution_index + .segments() + .map(|segment| { + std::mem::size_of::() + .saturating_add(segment.segment_digest.as_str().len()) + }) + .sum::(), + ); + let owner_rows = graph_owners.iter().map(Vec::len).sum::(); + let owner_bytes = super::resident_bytes::btree_bytes( + owner_rows, + std::mem::size_of::<(&tracedecay_domain::SymbolOccurrenceId, &FileOccurrenceId)>(), + ); + tracing::trace!( + phase = "graph", + graph_owner_count = owner_rows, + owner_map_bytes = owner_bytes, + retained_base_bytes = phase_base, + "cold resolution phase started" + ); + retain_facts(count(phase_base.saturating_add(owner_bytes))?)?; + let mut owners = BTreeMap::new(); + for (file_owners, descriptor) in graph_owners.iter().zip(&ordered) { + for occurrence in file_owners { + match owners.insert(occurrence, &descriptor.file_occurrence_id) { + Some(prior) if prior != &descriptor.file_occurrence_id => { + return Err(contract( + "one graph symbol occurrence belongs to multiple files", + )); + } + _ => {} + } + } + } + let graph_descriptors = snapshot + .files + .len() + .saturating_mul( + std::mem::size_of::() + "sha256:".len() + 64, + ) + .saturating_add( + snapshot + .files + .iter() + .map(|file| { + file.file_occurrence_id + .as_str() + .len() + .saturating_add(file.logical_path.len()) + }) + .sum::(), + ); + let key_bytes = super::resident_bytes::hash_table_bytes::<(&FileOccurrenceId, u32)>( + super::helpers::resolution_hash_capacity(snapshot.files.len()), + ); + let phase_base = phase_base + .saturating_add(graph_descriptors) + .saturating_add(key_bytes); + retain_facts(count(phase_base.saturating_add(owner_bytes))?)?; + let keys = snapshot + .files + .iter() + .enumerate() + .map(|(key, file)| { + Ok(( + &file.file_occurrence_id, + u32::try_from(key).map_err(|_| contract("code graph page file key exceeds u32"))?, + )) + }) + .collect::, CodeIndexProductionErrorV1>>()?; + let mut code_graph_pages = Vec::with_capacity(snapshot.files.len()); + super::graph_pages::for_each_code_graph_page( + snapshot, + &manifest.generation_id, + &owners, + &edges, + &unresolved, + &mut |workspace| { + retain_facts(count(phase_base.saturating_add(owner_bytes))?.saturating_add(workspace)) + }, + &mut |file| { + super::lexical_page_source::checkpoint(control)?; + if file.disposition != SnapshotFileDispositionV1::Present { + return Ok(None); + } + let key = keys + .get(&file.file_occurrence_id) + .ok_or_else(|| contract("code graph file is outside its snapshot"))?; + source.decode_file(*key).map(Some) + }, + &mut |page| { + super::lexical_page_source::checkpoint(control)?; + code_graph_pages.push(publish_code_graph_page(page, &BTreeSet::new(), publish)?); + if code_graph_pages.len().is_power_of_two() { + tracing::trace!( + phase = "graph", + completed_pages = code_graph_pages.len(), + total_files = snapshot.files.len(), + "cold graph publication progress" + ); + } + Ok(()) + }, + )?; + tracing::trace!( + phase = "graph", + descriptors = code_graph_pages.len(), + owner_map_bytes = owner_bytes, + retained_base_bytes = phase_base, + "cold resolution phase completed" + ); + drop(graph_span); + let statistics = super::CodeIndexGenerationStatisticsV1 { + source_total_bytes, + symbol_count, + edge_count: count(edges.len())?, + ambiguous_name_drops: Some(ambiguous_name_drops), + }; + Ok(StagedResolutionPartsV1 { + file_evidence, + code_graph_pages, + resolution_index, + coverage, + statistics, + }) } diff --git a/crates/tracedecay-code-index/src/production/typescript_resolution.rs b/crates/tracedecay-code-index/src/production/typescript_resolution.rs index 7740e6b7c6..be4b8bf520 100644 --- a/crates/tracedecay-code-index/src/production/typescript_resolution.rs +++ b/crates/tracedecay-code-index/src/production/typescript_resolution.rs @@ -32,8 +32,9 @@ use serde_json::Value; use tracedecay_code_extraction::ImportNamespaceV1; use tracedecay_domain::{RelationEdgeKindV1, blank_json_comments}; -use super::FileGenerationArtifactsV1; -use super::resolution_view::{ResolutionFileV1, SymbolsByNameV1}; +use super::CodeIndexProductionErrorV1; +use super::resolution_view::ResolutionMemoryChangeV1; +use super::resolution_view::{ResolutionFileFactsV1, ResolutionFileV1, SymbolsByNameV1}; use crate::chunks::{ CodeIndexImportEvidenceV1, is_typescript_family, relation_target_kind_is_compatible, }; @@ -54,9 +55,9 @@ const MAX_REEXPORT_DEPTH: usize = 16; /// One resolved import binding. #[derive(Clone, Debug, PartialEq, Eq)] -pub(super) enum ImportBindingOutcomeV1<'a> { +pub(super) enum ImportBindingOutcomeV1 { /// The exact defining symbol. - Bound(usize, &'a LineageSymbolRecordV1), + Bound(usize, Arc), /// The specifier names a dependency outside the indexed project. External, /// The specifier names project code the seal cannot reach or that does @@ -164,6 +165,50 @@ struct NodePackageV1 { subpath_exports: Vec<(String, Vec)>, } +impl AliasRuleV1 { + fn retained_heap_bytes(&self) -> usize { + self.prefix + .capacity() + .saturating_add(self.suffix.capacity()) + .saturating_add(super::resident_bytes::vec_bytes( + &self.targets, + self.targets.capacity(), + String::capacity, + )) + } +} +impl BuildMappingV1 { + fn retained_heap_bytes(&self) -> usize { + self.dir + .capacity() + .saturating_add(self.source_root.capacity()) + .saturating_add(self.output_root.capacity()) + } +} +impl NodePackageV1 { + fn retained_heap_bytes(&self) -> usize { + self.dir + .capacity() + .saturating_add(super::resident_bytes::vec_bytes( + &self.entries, + self.entries.capacity(), + String::capacity, + )) + .saturating_add(super::resident_bytes::vec_bytes( + &self.subpath_exports, + self.subpath_exports.capacity(), + |(path, rows)| { + path.capacity() + .saturating_add(super::resident_bytes::vec_bytes( + rows, + rows.capacity(), + String::capacity, + )) + }, + )) + } +} + /// Module facts for one sealed file set. pub(super) struct TypeScriptModuleIndexV1 { /// Indexed TypeScript-family source paths. @@ -180,10 +225,17 @@ pub(super) struct TypeScriptModuleIndexV1 { } impl TypeScriptModuleIndexV1 { - pub(super) fn new(files: &[T]) -> Self + pub(super) fn new(files: &[T]) -> Result where T: ResolutionFileV1, { + Self::new_charged(files, &mut |_| Ok(())) + } + + pub(super) fn new_charged( + files: &[T], + charge: &mut dyn FnMut(ResolutionMemoryChangeV1) -> Result<(), CodeIndexProductionErrorV1>, + ) -> Result { let mut sources = HashMap::new(); let mut packages = Vec::new(); let mut by_package_name: HashMap> = HashMap::new(); @@ -193,6 +245,8 @@ impl TypeScriptModuleIndexV1 { let path = file.logical_path(); let language = file.language(); if is_typescript_family(language) { + super::resolution_view::reserve_resolution_map(&mut sources, charge)?; + charge(ResolutionMemoryChangeV1::Retain(path.len()))?; sources.insert(path.to_owned(), index); continue; } @@ -201,42 +255,106 @@ impl TypeScriptModuleIndexV1 { } let (dir, name) = split_parent(path); if name == "package.json" { - let (package_name, package) = node_package(dir, &file.as_ref().artifacts.symbols); + let (package_name, package) = node_package(dir, file.binding()?.symbols()); if let Some(package_name) = package_name { + if !by_package_name.contains_key(&package_name) { + super::resolution_view::reserve_resolution_map( + &mut by_package_name, + charge, + )?; + charge(ResolutionMemoryChangeV1::Retain(package_name.capacity()))?; + } by_package_name .entry(package_name) .and_modify(|slot| *slot = None) .or_insert(Some(packages.len())); } + super::resolution_view::reserve_resolution_vec(&mut packages, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + package.retained_heap_bytes(), + ))?; packages.push(package); - build_mappings.extend(package_script_mappings( - dir, - &file.as_ref().artifacts.symbols, - )); + let mappings = package_script_mappings(dir, file.binding()?.symbols()); + super::resolution_view::reserve_resolution_vec( + &mut build_mappings, + mappings.len(), + charge, + )?; + charge(ResolutionMemoryChangeV1::Retain( + mappings + .iter() + .map(BuildMappingV1::retained_heap_bytes) + .sum(), + ))?; + build_mappings.extend(mappings); } else if (name.starts_with("tsconfig") && name.ends_with(".json")) || name == "jsconfig.json" { - let config = tsconfig(dir, &file.as_ref().artifacts.symbols); - if let Some(mapping) = tsconfig_build_mapping(dir, &file.as_ref().artifacts.symbols) - { + let config = tsconfig(dir, file.binding()?.symbols()); + if let Some(mapping) = tsconfig_build_mapping(dir, file.binding()?.symbols()) { + super::resolution_view::reserve_resolution_vec(&mut build_mappings, 1, charge)?; + charge(ResolutionMemoryChangeV1::Retain( + mapping.retained_heap_bytes(), + ))?; build_mappings.push(mapping); } - let merged = tsconfigs.entry(dir.to_owned()).or_default(); + if !tsconfigs.contains_key(dir) { + let before = super::resident_bytes::btree_bytes( + tsconfigs.len(), + std::mem::size_of::<(String, TsConfigV1)>(), + ); + let after = super::resident_bytes::btree_bytes( + tsconfigs.len().saturating_add(1), + std::mem::size_of::<(String, TsConfigV1)>(), + ); + charge(ResolutionMemoryChangeV1::Retain( + after.saturating_sub(before).saturating_add(dir.len()), + ))?; + tsconfigs.insert(dir.to_owned(), TsConfigV1::default()); + } + let merged = tsconfigs.get_mut(dir).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "TypeScript config insertion lost its rows".to_owned(), + ) + })?; + super::resolution_view::reserve_resolution_vec( + &mut merged.extends, + config.extends.len(), + charge, + )?; + charge(ResolutionMemoryChangeV1::Retain( + config.extends.iter().map(String::capacity).sum(), + ))?; + super::resolution_view::reserve_resolution_vec( + &mut merged.aliases, + config.aliases.len(), + charge, + )?; + charge(ResolutionMemoryChangeV1::Retain( + config + .aliases + .iter() + .map(AliasRuleV1::retained_heap_bytes) + .sum(), + ))?; merged.extends.extend(config.extends); merged.aliases.extend(config.aliases); if merged.base_url.is_none() { + charge(ResolutionMemoryChangeV1::Retain( + config.base_url.as_ref().map_or(0, String::capacity), + ))?; merged.base_url = config.base_url; } } } build_mappings.sort_by_key(|left| std::cmp::Reverse(left.output_root.len())); - Self { + Ok(Self { sources, packages, by_package_name, tsconfigs, build_mappings, - } + }) } /// The file `specifier` names from `from_path`, or why it names none. @@ -292,18 +410,18 @@ impl TypeScriptModuleIndexV1 { by_simple_name: &'a dyn SymbolsByNameV1, binding: &CodeIndexImportEvidenceV1, kind: RelationEdgeKindV1, - ) -> ImportBindingOutcomeV1<'a> + ) -> Result where T: ResolutionFileV1, { let Some(imported_name) = binding.imported_name.as_deref() else { - return ImportBindingOutcomeV1::Unresolved; + return Ok(ImportBindingOutcomeV1::Unresolved); }; let target = match self.resolve_specifier(&binding.logical_path, &binding.module_specifier) { ModuleTargetV1::File(index) => index, - ModuleTargetV1::External => return ImportBindingOutcomeV1::External, - ModuleTargetV1::Unresolved => return ImportBindingOutcomeV1::Unresolved, + ModuleTargetV1::External => return Ok(ImportBindingOutcomeV1::External), + ModuleTargetV1::Unresolved => return Ok(ImportBindingOutcomeV1::Unresolved), }; self.exported_symbol(files, by_simple_name, target, imported_name, kind) } @@ -319,18 +437,18 @@ impl TypeScriptModuleIndexV1 { binding: &CodeIndexImportEvidenceV1, members: &str, kind: RelationEdgeKindV1, - ) -> ImportBindingOutcomeV1<'a> + ) -> Result where T: ResolutionFileV1, { let Some(imported_name) = binding.imported_name.as_deref() else { - return ImportBindingOutcomeV1::Unresolved; + return Ok(ImportBindingOutcomeV1::Unresolved); }; let mut module = match self.resolve_specifier(&binding.logical_path, &binding.module_specifier) { ModuleTargetV1::File(index) => index, - ModuleTargetV1::External => return ImportBindingOutcomeV1::External, - ModuleTargetV1::Unresolved => return ImportBindingOutcomeV1::Unresolved, + ModuleTargetV1::External => return Ok(ImportBindingOutcomeV1::External), + ModuleTargetV1::Unresolved => return Ok(ImportBindingOutcomeV1::Unresolved), }; let (namespaces, member) = members.rsplit_once('.').unwrap_or(("", members)); let namespaces = (imported_name != "*") @@ -346,22 +464,26 @@ impl TypeScriptModuleIndexV1 { namespace, &mut visited, 0, - ) { + )? { NamespaceTargetV1::Module(next) => next, - NamespaceTargetV1::External => return ImportBindingOutcomeV1::External, + NamespaceTargetV1::External => return Ok(ImportBindingOutcomeV1::External), // `Uses` admits every symbol kind: any binding by that name is // a value whose members module resolution cannot see. NamespaceTargetV1::Missing => { - return match self.exported_symbol( - files, - by_simple_name, - module, - namespace, - RelationEdgeKindV1::Uses, - ) { - ImportBindingOutcomeV1::Bound(..) => ImportBindingOutcomeV1::ValueMember, - _ => ImportBindingOutcomeV1::Unresolved, - }; + return Ok( + match self.exported_symbol( + files, + by_simple_name, + module, + namespace, + RelationEdgeKindV1::Uses, + )? { + ImportBindingOutcomeV1::Bound(..) => { + ImportBindingOutcomeV1::ValueMember + } + _ => ImportBindingOutcomeV1::Unresolved, + }, + ); } }; } @@ -375,7 +497,7 @@ impl TypeScriptModuleIndexV1 { module: usize, name: &str, kind: RelationEdgeKindV1, - ) -> ImportBindingOutcomeV1<'a> + ) -> Result where T: ResolutionFileV1, { @@ -389,13 +511,13 @@ impl TypeScriptModuleIndexV1 { &mut HashSet::new(), 0, &mut found, - ); + )?; found.sort_by_key(|(index, symbol)| (*index, symbol.occurrence.clone())); found.dedup_by(|left, right| left.1.occurrence == right.1.occurrence); - match found.as_slice() { - [(index, symbol)] => ImportBindingOutcomeV1::Bound(*index, symbol), + Ok(match found.as_slice() { + [(index, symbol)] => ImportBindingOutcomeV1::Bound(*index, Arc::clone(symbol)), _ => ImportBindingOutcomeV1::Unresolved, - } + }) } /// The module namespace `name` denotes as an export of `file_index`, with @@ -409,23 +531,23 @@ impl TypeScriptModuleIndexV1 { name: &str, visited: &mut HashSet<(usize, String)>, depth: usize, - ) -> NamespaceTargetV1 + ) -> Result where T: ResolutionFileV1, { if depth > MAX_REEXPORT_DEPTH || !visited.insert((file_index, name.to_owned())) { - return NamespaceTargetV1::Missing; + return Ok(NamespaceTargetV1::Missing); } - let file = files[file_index].as_ref(); - let public = || file.artifacts.imports.iter().filter(|row| row.is_public); + let file = files[file_index].binding()?; + let public = || file.imports().iter().filter(|row| row.is_public); let mut forwarding = public() .filter(|row| !row.is_glob && row.local_name.as_deref() == Some(name)) .collect::>(); if forwarding.is_empty() { - if !defined_symbols(by_simple_name, file_index, name, RelationEdgeKindV1::Uses) + if !defined_symbols(by_simple_name, file_index, name, RelationEdgeKindV1::Uses)? .is_empty() { - return NamespaceTargetV1::Missing; + return Ok(NamespaceTargetV1::Missing); } if name != "default" { forwarding.extend(public().filter(|row| row.is_glob)); @@ -455,7 +577,7 @@ impl TypeScriptModuleIndexV1 { forwarded, visited, depth + 1, - ), + )?, ModuleTargetV1::File(next) => self.namespace_export( files, by_simple_name, @@ -463,21 +585,21 @@ impl TypeScriptModuleIndexV1 { forwarded, visited, depth + 1, - ), + )?, ModuleTargetV1::External => NamespaceTargetV1::External, ModuleTargetV1::Unresolved => NamespaceTargetV1::Missing, }; match resolved { - NamespaceTargetV1::Module(_) => return resolved, + NamespaceTargetV1::Module(_) => return Ok(resolved), NamespaceTargetV1::External => external = true, NamespaceTargetV1::Missing => {} } } - if external { + Ok(if external { NamespaceTargetV1::External } else { NamespaceTargetV1::Missing - } + }) } /// The module namespace a same-module export clause forwards: the module @@ -491,26 +613,28 @@ impl TypeScriptModuleIndexV1 { name: &str, visited: &mut HashSet<(usize, String)>, depth: usize, - ) -> NamespaceTargetV1 + ) -> Result where T: ResolutionFileV1, { let Some(binding) = - unique_local_import(files[file_index].as_ref(), name, RelationEdgeKindV1::Uses) + unique_local_import(files[file_index].binding()?, name, RelationEdgeKindV1::Uses) else { - return NamespaceTargetV1::Missing; + return Ok(NamespaceTargetV1::Missing); }; let Some(imported) = binding.imported_name.as_deref() else { - return NamespaceTargetV1::Missing; + return Ok(NamespaceTargetV1::Missing); }; - match self.resolve_specifier(&binding.logical_path, &binding.module_specifier) { - ModuleTargetV1::File(next) if imported == "*" => NamespaceTargetV1::Module(next), - ModuleTargetV1::File(next) => { - self.namespace_export(files, by_simple_name, next, imported, visited, depth) - } - ModuleTargetV1::External => NamespaceTargetV1::External, - ModuleTargetV1::Unresolved => NamespaceTargetV1::Missing, - } + Ok( + match self.resolve_specifier(&binding.logical_path, &binding.module_specifier) { + ModuleTargetV1::File(next) if imported == "*" => NamespaceTargetV1::Module(next), + ModuleTargetV1::File(next) => { + self.namespace_export(files, by_simple_name, next, imported, visited, depth)? + } + ModuleTargetV1::External => NamespaceTargetV1::External, + ModuleTargetV1::Unresolved => NamespaceTargetV1::Missing, + }, + ) } #[allow(clippy::too_many_arguments)] @@ -523,15 +647,16 @@ impl TypeScriptModuleIndexV1 { kind: RelationEdgeKindV1, visited: &mut HashSet<(usize, String)>, depth: usize, - found: &mut Vec<(usize, &'a LineageSymbolRecordV1)>, - ) where + found: &mut Vec<(usize, Arc)>, + ) -> Result<(), CodeIndexProductionErrorV1> + where T: ResolutionFileV1, { if depth > MAX_REEXPORT_DEPTH || !visited.insert((file_index, name.to_owned())) { - return; + return Ok(()); } - let file = files[file_index].as_ref(); - let public = || file.artifacts.imports.iter().filter(|row| row.is_public); + let file = files[file_index].binding()?; + let public = || file.imports().iter().filter(|row| row.is_public); // An export clause naming `name` is the export itself; a same-named // declaration beside `export { a as name }` is a different binding. // Either one shadows whatever `export *` forwards, and `export *` @@ -540,10 +665,10 @@ impl TypeScriptModuleIndexV1 { .filter(|row| !row.is_glob && row.local_name.as_deref() == Some(name)) .collect::>(); if forwarding.is_empty() { - let defined = defined_symbols(by_simple_name, file_index, name, kind); + let defined = defined_symbols(by_simple_name, file_index, name, kind)?; if !defined.is_empty() { found.extend(defined); - return; + return Ok(()); } if name != "default" { forwarding.extend(public().filter(|row| row.is_glob)); @@ -576,7 +701,7 @@ impl TypeScriptModuleIndexV1 { visited, depth + 1, found, - ); + )?; } else { self.collect_exported_symbols( files, @@ -587,9 +712,10 @@ impl TypeScriptModuleIndexV1 { visited, depth + 1, found, - ); + )?; } } + Ok(()) } /// The module-scope binding `name` a same-module export clause forwards: @@ -604,24 +730,25 @@ impl TypeScriptModuleIndexV1 { kind: RelationEdgeKindV1, visited: &mut HashSet<(usize, String)>, depth: usize, - found: &mut Vec<(usize, &'a LineageSymbolRecordV1)>, - ) where + found: &mut Vec<(usize, Arc)>, + ) -> Result<(), CodeIndexProductionErrorV1> + where T: ResolutionFileV1, { - let defined = defined_symbols(by_simple_name, file_index, name, kind); + let defined = defined_symbols(by_simple_name, file_index, name, kind)?; if !defined.is_empty() { found.extend(defined); - return; + return Ok(()); } - let Some(binding) = unique_local_import(files[file_index].as_ref(), name, kind) else { - return; + let Some(binding) = unique_local_import(files[file_index].binding()?, name, kind) else { + return Ok(()); }; let Some(imported) = binding .imported_name .as_deref() .filter(|imported| *imported != "*") else { - return; + return Ok(()); }; if let ModuleTargetV1::File(next) = self.resolve_specifier(&binding.logical_path, &binding.module_specifier) @@ -635,8 +762,9 @@ impl TypeScriptModuleIndexV1 { visited, depth, found, - ); + )?; } + Ok(()) } /// Rewrite a missing published path through a declared compiler mapping. @@ -1233,31 +1361,30 @@ fn tsconfig(dir: &str, symbols: &[Arc]) -> TsConfigV1 { config } -fn defined_symbols<'a>( - by_simple_name: &'a dyn SymbolsByNameV1, +fn defined_symbols( + by_simple_name: &dyn SymbolsByNameV1, file_index: usize, name: &str, kind: RelationEdgeKindV1, -) -> Vec<(usize, &'a LineageSymbolRecordV1)> { - by_simple_name - .get(name) +) -> Result)>, CodeIndexProductionErrorV1> { + Ok(by_simple_name + .get(name)? .into_iter() .flatten() .filter(|(index, symbol)| { *index == file_index && relation_target_kind_is_compatible(kind, &symbol.kind) }) - .map(|(index, symbol)| (*index, symbol.as_ref())) - .collect() + .collect()) } /// The one local (non-forwarding) import binding of `local_name` usable for /// `relation`, or `None` when the file binds it zero or several times. pub(super) fn unique_local_import<'a>( - file: &'a FileGenerationArtifactsV1, + file: &'a dyn ResolutionFileFactsV1, local_name: &str, relation: RelationEdgeKindV1, ) -> Option<&'a CodeIndexImportEvidenceV1> { - let mut matches = file.artifacts.imports.iter().filter(|binding| { + let mut matches = file.imports().iter().filter(|binding| { !binding.is_public && binding.local_name.as_deref() == Some(local_name) && match relation { diff --git a/crates/tracedecay-code-index/src/production/worker_tests.rs b/crates/tracedecay-code-index/src/production/worker_tests.rs index ba09d63f12..17ff536b67 100644 --- a/crates/tracedecay-code-index/src/production/worker_tests.rs +++ b/crates/tracedecay-code-index/src/production/worker_tests.rs @@ -1,4 +1,8 @@ use std::sync::atomic::{AtomicUsize, Ordering}; +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, ProcessResidentSampleV1, ResidentMemoryPressureV1, + detected_process_resident_memory_limit_v1, +}; use sha2::Digest as _; @@ -11,6 +15,20 @@ use super::*; pub(super) type WorkerPublicationStore = MemorySealedPublicationStoreV1; +pub(super) fn decoded_generation( + published: &CodeIndexPublishedBuildV1, +) -> Arc { + let reader = published.sealed_generation().segment_reader(); + Arc::new( + CodeIndexPublishedGenerationV1::decode_partitioned_sealed( + published.manifest_bytes(), + &SharedDecodedContentPoolV1::default(), + |request, buffer| reader(request, buffer), + ) + .expect("published generation decodes"), + ) +} + pub(super) fn worker_id(value: &str) -> T where T: TryFrom, @@ -45,6 +63,9 @@ pub(super) fn worker_request_with_source( disposition: SnapshotFileDispositionV1::Present, }; CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot: SanitizedCodeSnapshotV1 { repository: worker_id("repository.worker"), worktree: None, @@ -94,6 +115,285 @@ impl From for WorkerTestError { } } +fn construction_ledger(limit: u64) -> Arc { + let limit = NonZeroU64::new(limit).expect("nonzero test budget"); + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler( + limit, + Arc::new(|| { + Some(ProcessResidentSampleV1 { + resident_bytes: 0, + unreclaimable_bytes: 0, + swapped_bytes: 0, + cgroup_headroom_bytes: None, + }) + }), + )); + Arc::new(ProcessResidentMemoryV1::with_pressure(limit, pressure)) +} + +#[test] +fn canonical_construction_admission_precedes_hashing_and_releases_its_guards() { + let mut request = + worker_request_with_source("file.worker.hash-admission", 1_100_000, b"fn target() {}\n"); + request.resident_memory = construction_ledger(64 * 1024 * 1024); + let workspace = metadata_hash_workspace( + &{ + let mut owner = + CodeIndexProductionOwnerV1::new(worker_config(), WorkerPublicationStore::default()) + .unwrap(); + owner + .build_and_publish(request.clone(), &UninterruptibleCodeIndexControlV1) + .unwrap() + .manifest() + .clone() + }, + &request.snapshot, + ) + .unwrap(); + let ledger = construction_ledger(1024); + let operation = ledger.begin_operation(); + let mut memory = ConstructionMemoryV1::new(operation.clone()).unwrap(); + let entered = std::cell::Cell::new(false); + let denied: Result = memory.canonical(0, workspace, || { + entered.set(true); + canonical_sha256(&request.snapshot) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string())) + }); + assert!(matches!( + denied, + Err(CodeIndexProductionErrorV1::Publication( + CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused(_) + )) + )); + assert!( + !entered.get(), + "a refused preflight must never enter canonical serialization" + ); + assert!( + operation + .witnessed_reconstruction_requirement(ledger.limit_bytes()) + .unwrap() + .is_some() + ); + drop(memory); + assert_eq!(ledger.snapshot().used_bytes, 0); + + let ledger = construction_ledger(16 * 1024 * 1024); + let mut memory = ConstructionMemoryV1::new(ledger.begin_operation()).unwrap(); + let expected = canonical_sha256(&request.snapshot).unwrap(); + let actual = memory + .canonical(0, workspace, || { + canonical_sha256(&request.snapshot) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string())) + }) + .unwrap(); + assert_eq!( + actual, expected, + "admission must preserve the canonical hash bytes" + ); + assert!(ledger.snapshot().used_bytes >= memory.charged_bytes); + drop(memory); + assert_eq!(ledger.snapshot().used_bytes, 0); +} + +#[test] +fn cold_finalization_denies_before_emitting_and_preserves_sealed_wire_bytes() { + let mut owner = + CodeIndexProductionOwnerV1::new(worker_config(), WorkerPublicationStore::default()) + .unwrap(); + let mut request = worker_request_with_source( + "file.worker.finalization-admission", + 1_100_000, + b"pub fn target() {}\npub fn caller() { target(); }\n", + ); + request.resident_memory = construction_ledger(64 * 1024 * 1024); + let built = owner + .build_and_publish(request, &UninterruptibleCodeIndexControlV1) + .unwrap(); + let decoded = decoded_generation(&built); + assert_eq!( + partitioned_seal(&decoded).0, + built.manifest_bytes().as_ref(), + "cold charged finalization must match the complete-input encoder byte for byte" + ); + let held = built.publication().construction_memory.as_ref().unwrap(); + let retained = resident_bytes::projection_heap_bytes(built.publication().projection()) + .saturating_add(built.manifest_bytes().len()) + .saturating_add(resident_bytes::compact_metadata_bytes( + &built.publication().metadata, + resident_bytes::chunk_policy_heap_bytes(decoded.chunk_policy_summary()), + )); + assert!( + held.charged_bytes as usize >= retained, + "compact metadata must remain charged after preparation" + ); + + let changes = &decoded.projection.request().changes.added_or_changed; + let keys = vec![0_u32; changes.len()]; + let denied_ledger = construction_ledger(1); + let mut denied_memory = ConstructionMemoryV1::new(denied_ledger.begin_operation()).unwrap(); + assert!( + projection_rows::FileChunkRostersV1::from_changes(changes, &keys, 0, &mut |bytes| { + denied_memory.resize(bytes) + }) + .is_err() + ); + let ledger = construction_ledger(16 * 1024 * 1024); + let mut memory = ConstructionMemoryV1::new(ledger.begin_operation()).unwrap(); + let rosters = + projection_rows::FileChunkRostersV1::from_changes(changes, &keys, 0, &mut |bytes| { + memory.resize(bytes) + }) + .unwrap(); + assert!( + partitioned_codec::PartitionedGenerationEvidenceRefV1::new_charged( + &decoded.projection, + &rosters, + None, + 0, + &mut |bytes| denied_memory.resize(bytes) + ) + .is_err() + ); + let evidence = partitioned_codec::PartitionedGenerationEvidenceRefV1::new_charged( + &decoded.projection, + &rosters, + None, + rosters.retained_bytes(), + &mut |bytes| memory.resize(bytes), + ) + .unwrap(); + let mut published = 0; + let refused = partitioned_codec::write_generation_evidence_charged( + &evidence, + 0, + &mut |bytes| denied_memory.resize(bytes), + &mut |_| { + published += 1; + Ok(()) + }, + ); + assert!(matches!( + refused, + Err(CodeIndexProductionErrorV1::Publication( + CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused(_) + )) + )); + assert_eq!( + published, 0, + "denied evidence admission must precede the first page and pack commit" + ); + let mut pages = Vec::new(); + let generation_evidence = partitioned_codec::write_generation_evidence_charged( + &evidence, + rosters + .retained_bytes() + .saturating_add(evidence.retained_bytes()), + &mut |bytes| memory.resize(bytes), + &mut |publication| { + if let SealedGenerationSegmentPublicationV1::GenerationEvidencePage { bytes, .. } = + publication + { + pages.extend_from_slice(bytes); + } + Ok(()) + }, + ) + .unwrap(); + assert_eq!(pages, serde_json::to_vec(&evidence).unwrap()); + assert_eq!( + generation_evidence.segment_digest, + ManifestDigest::from_sha256_bytes(&sha2::Sha256::digest(&pages)).unwrap() + ); + + let parsed = partitioned_codec::parse_partitioned_manifest(built.manifest_bytes()).unwrap(); + let generation_ref = partitioned_codec::PartitionedPublishedGenerationRefV1 { + format_revision: SEALED_GENERATION_FORMAT_REVISION_V1, + manifest: &parsed.manifest, + snapshot: &parsed.snapshot, + statistics: &parsed.statistics, + chunk_count: parsed.chunk_count, + chunk_policy: &parsed.chunk_policy, + repository_parse_identity: &parsed.repository_parse_identity, + ignored_source_admissions: &parsed.ignored_source_admissions, + ignored_source_admissions_digest: &parsed.ignored_source_admissions_digest, + file_segments: &parsed.file_segments, + file_evidence: &parsed.file_evidence, + coverage: parsed.coverage, + capability: &parsed.capability, + generation_evidence: &parsed.generation_evidence, + code_graph_pages: &parsed.code_graph_pages, + resolution_index: &parsed.resolution_index, + }; + assert!( + partitioned_codec::seal_partitioned_manifest_charged(&generation_ref, 0, &mut |bytes| { + denied_memory.resize(bytes) + }) + .is_err() + ); + let raw_length = partitioned_codec::encoded_json_rows_length(&generation_ref).unwrap(); + let envelope_ledger = construction_ledger((raw_length + 71) as u64); + let mut envelope_memory = ConstructionMemoryV1::new(envelope_ledger.begin_operation()).unwrap(); + let envelope_denied = + partitioned_codec::seal_partitioned_manifest_charged(&generation_ref, 0, &mut |bytes| { + envelope_memory.resize(bytes) + }); + assert!(matches!( + envelope_denied, + Err(CodeIndexProductionErrorV1::Publication( + CodeIndexPublicationStoreErrorV1::ResidentMemoryRefused(_) + )) + )); + assert_eq!( + envelope_memory.charged_bytes, + (raw_length + 71) as u64, + "the raw manifest must be admitted before the overlapping envelope is refused" + ); + drop(envelope_memory); + assert_eq!(envelope_ledger.snapshot().used_bytes, 0); + let encoded = + partitioned_codec::seal_partitioned_manifest_charged(&generation_ref, 0, &mut |bytes| { + memory.resize(bytes) + }) + .unwrap(); + let raw = serde_json::to_vec(&generation_ref).unwrap(); + let digest = ManifestDigest::from_sha256_bytes(&sha2::Sha256::digest(&raw)).unwrap(); + let expected = format!( + "{{\"state_digest\":{},\"generation\":{}}}", + serde_json::to_string(&digest).unwrap(), + String::from_utf8(raw).unwrap() + ); + assert_eq!( + encoded, + expected.as_bytes(), + "the pre-admission codec keeps the established envelope and state hash" + ); + assert_eq!(encoded, built.manifest_bytes().as_ref()); + drop(evidence); + drop(rosters); + drop(memory); + drop(denied_memory); + assert_eq!(ledger.snapshot().used_bytes, 0); + assert_eq!(denied_ledger.snapshot().used_bytes, 0); +} + +#[test] +fn cold_publication_reports_computed_clone_payloads() { + let source = b"pub fn transform(input: u32) -> u32 { let doubled = input * 2; let shifted = doubled + 3; shifted * shifted + doubled }\n"; + let mut owner = + CodeIndexProductionOwnerV1::new(worker_config(), WorkerPublicationStore::default()) + .expect("production owner"); + let built = owner + .build_and_publish( + worker_request_with_source("file.worker.clone-statistics", 1_100_000, source), + &UninterruptibleCodeIndexControlV1, + ) + .expect("cold publication"); + let decoded = decoded_generation(&built); + assert!(!decoded.files[0].artifacts.clone_bodies.is_empty()); + assert_eq!(built.publication().clone_update_statistics(), (0, 0, true)); +} + #[test] fn fresh_generation_resolves_seal_references_once() { let mut owner = @@ -134,7 +434,7 @@ fn sealed_generation_is_reusable_by_a_later_build_with_the_same_stored_shape() { &UninterruptibleCodeIndexControlV1, ) .expect("sealed generation"); - let (manifest, segments) = partitioned_seal(published.decoded().expect("cold seal")); + let (manifest, segments) = partitioned_seal(&decoded_generation(&published)); drop(sealing_build); let restored = partitioned_restore(&manifest, &segments); @@ -177,6 +477,350 @@ fn sealed_generation_is_reusable_by_a_later_build_with_the_same_stored_shape() { ); } +/// Keep the real store's preparation, reads, verification, and CAS. The test +/// rejects only attempts to restage already authenticated reusable segments. +#[derive(Default)] +struct ParentReuseStore { + inner: WorkerPublicationStore, + unchanged_file: Option, + parent_manifest: Option>, +} + +struct ParentReusePreparation { + inner: MemoryCodeIndexPreparationV1, + unchanged_file: Option, + parent_segments: partitioned_codec::ParentSegmentReuseRosterV1, +} + +impl CodeIndexPreparationPort for ParentReusePreparation { + fn write_segment( + &mut self, + publication: SealedGenerationSegmentPublicationV1<'_>, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + if self.parent_segments.contains(publication) { + return Err(CodeIndexPublicationStoreErrorV1::Unavailable( + "authenticated parent evidence, graph, and index segments must not be staged again" + .to_owned(), + )); + } + if let SealedGenerationSegmentPublicationV1::File { digest, .. } = publication + && self.unchanged_file.as_ref() == Some(digest) + { + return Err(CodeIndexPublicationStoreErrorV1::Unavailable( + "unchanged parent file must not be encoded and staged again".to_owned(), + )); + } + self.inner.write_segment(publication) + } + + fn segment_reader(&self) -> Arc { + self.inner.segment_reader() + } + + fn abort(self) -> Result<(), CodeIndexPublicationStoreErrorV1> { + self.inner.abort() + } +} + +impl CodeIndexAtomicPublicationPort for ParentReuseStore { + type Preparation = ParentReusePreparation; + + fn load_active( + &self, + scope: &CodeIndexGenerationScopeV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + self.inner.load_active(scope) + } + + fn prepare( + &mut self, + scope: &CodeIndexGenerationScopeV1, + expected: Option<&CodeGenerationId>, + ) -> Result { + let parent = self + .parent_manifest + .as_ref() + .map(|bytes| partitioned_codec::parse_partitioned_manifest(bytes)) + .transpose() + .map_err(|error| CodeIndexPublicationStoreErrorV1::Unavailable(error.to_string()))?; + let parent_segments = + partitioned_codec::ParentSegmentReuseRosterV1::new(parent.as_ref(), &mut |_| Ok(())) + .map_err(|error| { + CodeIndexPublicationStoreErrorV1::Unavailable(error.to_string()) + })?; + Ok(ParentReusePreparation { + inner: self.inner.prepare(scope, expected)?, + unchanged_file: self.unchanged_file.clone(), + parent_segments, + }) + } + + fn commit( + &mut self, + preparation: &mut Self::Preparation, + publication: &CodeIndexSealedPublicationV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + self.inner.commit(&mut preparation.inner, publication) + } +} + +fn parent_reuse_request(large: &[u8], edited: &[u8], sealed_at: i64) -> CodeIndexBuildRequestV1 { + let mut request = worker_request_with_source("file.worker.large", sealed_at, large); + request.resident_memory = construction_ledger(64 * 1024 * 1024); + request.snapshot.files[0].logical_path = "src/large.rs".to_owned(); + let other = worker_request_with_source("file.worker.edited", sealed_at, edited); + let mut edited_file = other.snapshot.files[0].clone(); + edited_file.logical_path = "src/edited.rs".to_owned(); + request.snapshot.files.push(edited_file); + request + .snapshot + .files + .sort_by(|left, right| left.logical_path.cmp(&right.logical_path)); + request.snapshot.content_identity = + worker_id(canonical_sha256(&request.snapshot.files).unwrap().as_str()); + request.captured_files.extend(other.captured_files); + request.changed_files.insert("src/edited.rs".to_owned()); + request +} + +#[test] +fn cold_changed_share_reuses_parent_files_without_restaging_and_keeps_canonical_evidence() { + let large = (0..128).map(|index| { + format!("// unchanged source padding xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\npub fn unchanged_{index}() -> usize {{ {index} }}\n") + }).collect::(); + let mut owner = + CodeIndexProductionOwnerV1::new(worker_config(), ParentReuseStore::default()).unwrap(); + let first = owner + .build_and_publish( + parent_reuse_request( + large.as_bytes(), + b"pub fn edited() -> usize { 1 }\n", + 1_100_000, + ), + &UninterruptibleCodeIndexControlV1, + ) + .unwrap(); + let parent = SealedParentGenerationV1::open(first.sealed_generation().clone()).unwrap(); + let large_descriptor = parent.file_segment("src/large.rs").unwrap().clone(); + owner.publication_mut().unchanged_file = Some(large_descriptor.segment_digest.clone()); + owner.publication_mut().parent_manifest = Some(Arc::clone(first.manifest_bytes())); + let next = owner + .build_and_publish( + parent_reuse_request( + large.as_bytes(), + b"pub fn edited() -> usize { 2 }\n", + 1_200_000, + ), + &UninterruptibleCodeIndexControlV1, + ) + .expect("cold fallback must carry the existing large file without staging it"); + assert_eq!( + next.cold_reason(), + Some(CodeIndexColdBuildReasonV1::ChangedShare) + ); + let next_parent = SealedParentGenerationV1::open(next.sealed_generation().clone()).unwrap(); + assert_eq!( + next_parent.file_segment("src/large.rs").unwrap(), + &large_descriptor + ); + assert_ne!( + next_parent + .file_segment("src/edited.rs") + .unwrap() + .segment_digest, + parent.file_segment("src/edited.rs").unwrap().segment_digest + ); + let restored = decoded_generation(&next); + assert_eq!(restored.projection(), next.projection()); + restored.validate_fresh().unwrap(); + let reusable = + partitioned_codec::ParentSegmentReuseRosterV1::new(Some(parent.generation()), &mut |_| { + Ok(()) + }) + .unwrap(); + let mut staged_files = Vec::new(); + let full_input = restored.encode_partitioned_sealed_with_parent(Some(first.manifest_bytes()), |segment| { + assert!(!reusable.contains(segment), "complete-input sealing must also suppress the parent's authenticated non-file segments"); + if let SealedGenerationSegmentPublicationV1::File { digest, .. } = segment { + staged_files.push(digest.clone()); + } + Ok(()) + }).unwrap(); + assert!(!staged_files.contains(&large_descriptor.segment_digest)); + assert_eq!( + full_input.as_slice(), + next.manifest_bytes().as_ref(), + "cold reuse and complete-input reuse must seal identical descriptors, projection evidence, and hashes" + ); + + let policy = CodeIndexProductionConfigV1 { + policy_revision: worker_id("policy.v2"), + ..worker_config() + }; + let mut policy_owner = CodeIndexProductionOwnerV1::new( + policy, + ParentReuseStore { + inner: owner.publication_mut().inner.clone(), + unchanged_file: Some(large_descriptor.segment_digest.clone()), + parent_manifest: None, + }, + ) + .unwrap(); + let rebuilt = policy_owner + .build_and_publish( + parent_reuse_request( + large.as_bytes(), + b"pub fn edited() -> usize { 2 }\n", + 1_300_000, + ), + &UninterruptibleCodeIndexControlV1, + ) + .unwrap(); + assert_eq!( + rebuilt.cold_reason(), + Some(CodeIndexColdBuildReasonV1::NoParent) + ); + let rebuilt_parent = + SealedParentGenerationV1::open(rebuilt.sealed_generation().clone()).unwrap(); + assert_ne!( + rebuilt_parent + .file_segment("src/large.rs") + .unwrap() + .segment_digest, + large_descriptor.segment_digest, + "a policy mismatch must not carry the old file segment" + ); +} + +#[test] +fn parent_file_reuse_requires_snapshot_parser_and_symbol_identity_evidence_before_admission() { + let mut owner = + CodeIndexProductionOwnerV1::new(worker_config(), WorkerPublicationStore::default()) + .unwrap(); + let published = owner + .build_and_publish( + worker_request_with_source( + "file.worker.reuse-evidence", + 1_100_000, + b"pub fn target() {}\n", + ), + &UninterruptibleCodeIndexControlV1, + ) + .unwrap(); + let parent = SealedParentGenerationV1::open(published.sealed_generation().clone()).unwrap(); + let descriptor = parent.file_segment("src/lib.rs").unwrap(); + let row = &parent.snapshot().files[descriptor.file_key as usize]; + let artifact = parent.decode_parent_file("src/lib.rs").unwrap(); + let prior = Some((parent.manifest(), row, descriptor)); + let ledger = construction_ledger(1024 * 1024); + let mut memory = ConstructionMemoryV1::new(ledger.begin_operation()).unwrap(); + let reused = partitioned_codec::reusable_file_segment( + parent.manifest(), + row, + &artifact, + descriptor.file_key, + prior, + &mut |bytes| memory.resize(bytes), + ) + .unwrap() + .unwrap(); + assert_eq!(&reused, descriptor); + assert_eq!( + ledger.snapshot().used_bytes, + 0, + "identity-list guards must drop before returning the descriptor" + ); + let denied_ledger = construction_ledger(1); + let mut denied = ConstructionMemoryV1::new(denied_ledger.begin_operation()).unwrap(); + assert!( + partitioned_codec::reusable_file_segment( + parent.manifest(), + row, + &artifact, + descriptor.file_key, + prior, + &mut |bytes| denied.resize(bytes) + ) + .is_err() + ); + + let mut different_row = row.clone(); + different_row.file_occurrence_id = worker_id("file.worker.another-occurrence"); + assert!( + partitioned_codec::reusable_file_segment( + parent.manifest(), + &different_row, + &artifact, + descriptor.file_key, + prior, + &mut |bytes| denied.resize(bytes) + ) + .unwrap() + .is_none() + ); + let mut different_parser = parent.manifest().clone(); + different_parser.extractor_revisions[0].1 = worker_id("extractor.rust.v3"); + assert!( + partitioned_codec::reusable_file_segment( + &different_parser, + row, + &artifact, + descriptor.file_key, + prior, + &mut |bytes| denied.resize(bytes) + ) + .unwrap() + .is_none() + ); + let mut different_artifact = (*artifact).clone(); + let mut symbol = (*different_artifact.artifacts.symbols[0]).clone(); + symbol.identity = worker_id(&format!("sha256:{}", "f".repeat(64))); + different_artifact.artifacts.symbols[0] = Arc::new(symbol); + assert!( + partitioned_codec::reusable_file_segment( + parent.manifest(), + row, + &different_artifact, + descriptor.file_key, + prior, + &mut |bytes| memory.resize(bytes) + ) + .unwrap() + .is_none() + ); + assert_eq!(ledger.snapshot().used_bytes, 0); + assert!( + partitioned_codec::ParentSegmentReuseRosterV1::new( + Some(parent.generation()), + &mut |bytes| denied.resize(bytes) + ) + .is_err() + ); + let roster = partitioned_codec::ParentSegmentReuseRosterV1::new( + Some(parent.generation()), + &mut |bytes| memory.resize(bytes), + ) + .unwrap(); + assert!( + ledger.snapshot().used_bytes > 0, + "the parent digest roster must hold its admitted storage" + ); + let evidence = &parent.generation().generation_evidence; + assert!( + !roster.contains( + SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { + segment_digest: &evidence.segment_digest, + segment_size_bytes: evidence.segment_size_bytes, + page_count: evidence.pages.len().try_into().unwrap(), + } + ), + "a parent evidence pack must never suppress a new generation evidence commit" + ); + drop(roster); + memory.resize(0).unwrap(); + assert_eq!(ledger.snapshot().used_bytes, 0); +} + /// An unchanged tree over its sealed parent seals over it: no file is /// extracted again and every segment and graph page carries. #[test] @@ -263,15 +907,14 @@ fn extractor_revision_change_reextracts_before_validating_retained_import_rows() active.validated = OnceLock::new(); let mut publisher = store.clone(); + let mut preparation = publisher + .prepare(&scope, Some(&incumbent)) + .expect("historical preparation"); + let publication = active + .stage_publication(&mut preparation) + .expect("historical generation stages through the canonical codec"); publisher - .publish_atomically( - &scope, - Some(&incumbent), - &CodeIndexSealedPublicationV1::Cold( - Arc::new(active), - CodeIndexColdBuildReasonV1::NoParent, - ), - ) + .commit(&mut preparation, &publication) .expect("historical generation seals"); } @@ -288,7 +931,7 @@ fn extractor_revision_change_reextracts_before_validating_retained_import_rows() rebuilt.cold_reason(), Some(CodeIndexColdBuildReasonV1::NoParent) ); - let rebuilt = rebuilt.decoded().expect("cold build"); + let rebuilt = decoded_generation(&rebuilt); assert_eq!( rebuilt.files[0].extraction.extractor_revision.as_str(), "extractor.rust.v21" @@ -300,6 +943,233 @@ fn extractor_revision_change_reextracts_before_validating_retained_import_rows() assert_eq!(upgraded.retained_parse_stats().full_extractions, 1); } +/// Retain the output of the same intake, planning and extraction path the +/// constructor stages one window at a time. The test is the explicit strong +/// owner; the physical pool remains only its weak index. +fn retained_worker_artifact( + request: &CodeIndexBuildRequestV1, + pool: &SharedPhysicalCodeArtifactPoolV1, +) -> (ManifestDigest, Arc) { + let config = worker_config(); + let intake = SanitizedCodeIntake::new( + StaticLanguageRegistry::new(), + config.sanitizer_revision.clone(), + request.sealed_at, + ); + let capability = intake + .admit(request.snapshot.clone()) + .expect("artifact intake"); + let planner = GenerationPlanner::new( + config.project_id.clone(), + config.repository.clone(), + StaticLanguageRegistry::new(), + config.chunker_revision.clone(), + config.privacy_domain.clone(), + config.privacy_key_epoch, + ); + let manifest = planner + .plan_generation_with_invalidation( + capability.snapshot(), + None, + &request.invalidations, + request.sealed_at, + ) + .expect("artifact generation plan"); + let chunker = DeterministicCodeChunker::new( + manifest.generation_id.clone(), + config.repository.clone(), + config.sanitizer_revision.clone(), + config.policy_revision.clone(), + config.chunker_revision.clone(), + ); + let captured = request + .captured_files + .iter() + .cloned() + .map(|file| (file.file_occurrence_id.clone(), file)) + .collect(); + let (key, artifact, _) = materialize_present_file( + &config, + pool, + &SharedRetainedParsePool::default(), + &intake, + &capability, + &manifest, + &TreeSitterExtractor::new(), + &chunker, + &request.repository_parse_identity, + None, + &request.snapshot.files[0], + &captured, + &UninterruptibleCodeIndexControlV1, + ) + .expect("canonical retained file extraction"); + pool.insert(key.clone(), &artifact); + (key, artifact) +} + +#[test] +fn held_physical_artifacts_share_payloads_and_rebind_without_pinning_the_corpus() { + let source = b"pub fn transform(input: u32) -> u32 {\n let doubled = input * 2;\n let shifted = doubled + 3;\n shifted * shifted + doubled\n}\n"; + let pool = SharedPhysicalCodeArtifactPoolV1::default(); + let seed_request = worker_request_with_source("file.worker.shared", 1_100_000, source); + let (key, held) = retained_worker_artifact(&seed_request, &pool); + assert_eq!(pool.stats().resident, 1); + assert_eq!(pool.stats().inserted, 1); + assert_eq!(pool.stats().reused, 0); + assert!( + !held.artifacts.clone_bodies.is_empty(), + "the fixture has shared payloads" + ); + + let request = worker_request_with_source("file.worker.shared", 1_200_000, source); + let borrowed = std::thread::scope(|scope| { + scope + .spawn(|| retained_worker_artifact(&request, &pool).1) + .join() + .expect("artifact borrower") + }); + assert_eq!(pool.stats().reused, 1); + assert_eq!(pool.stats().resident, 1); + assert_eq!( + held.artifacts.clone_bodies.len(), + borrowed.artifacts.clone_bodies.len() + ); + for (source, rebound) in held + .artifacts + .clone_bodies + .iter() + .zip(&borrowed.artifacts.clone_bodies) + { + assert!(Arc::ptr_eq(&source.payload, &rebound.payload)); + } + let (_, cold) = + retained_worker_artifact(&request, &SharedPhysicalCodeArtifactPoolV1::default()); + assert_eq!(borrowed.authority, cold.authority); + assert_eq!(borrowed.extraction, cold.extraction); + assert_eq!( + borrowed.artifacts, cold.artifacts, + "cache reuse preserves every file artifact" + ); + borrowed + .exact_authority + .validate_all(&borrowed.artifacts.chunks.chunks) + .expect("borrowed chunks retain parser-backed exact authority"); + cold.exact_authority + .validate_all(&cold.artifacts.chunks.chunks) + .expect("cold comparison chunks retain parser-backed exact authority"); + + let foreign_id = worker_id::("file.worker.foreign"); + let foreign_request = worker_request_with_source(foreign_id.as_str(), 1_200_000, source); + let (_, foreign) = retained_worker_artifact(&foreign_request, &pool); + assert_eq!(pool.stats().reused, 2); + assert_eq!( + pool.stats().inserted, + 1, + "occurrence identity is outside the physical key" + ); + assert_eq!(foreign.extraction.file_occurrence_id, foreign_id); + assert!(!foreign.artifacts.chunks.chunks.is_empty()); + assert!( + foreign + .artifacts + .chunks + .chunks + .iter() + .all(|chunk| chunk.anchor.file_occurrence_id == foreign_id) + ); + for (source, rebound) in held + .artifacts + .clone_bodies + .iter() + .zip(&foreign.artifacts.clone_bodies) + { + assert!(Arc::ptr_eq(&source.payload, &rebound.payload)); + } + let (_, foreign_cold) = retained_worker_artifact( + &foreign_request, + &SharedPhysicalCodeArtifactPoolV1::default(), + ); + assert_eq!(foreign.artifacts, foreign_cold.artifacts); + foreign + .exact_authority + .validate_all(&foreign.artifacts.chunks.chunks) + .expect("foreign chunks have their own exact authority"); + assert!( + foreign + .exact_authority + .validate_all(&held.artifacts.chunks.chunks) + .is_err(), + "foreign exact authority cannot admit the source occurrence's chunks" + ); + + let changed_request = worker_request_with_source( + "file.worker.changed", + 1_300_000, + b"pub fn changed() -> u32 { 9 }\n", + ); + let (_, changed) = retained_worker_artifact(&changed_request, &pool); + assert_eq!( + changed.authority.content_digest, + changed_request.snapshot.files[0].content_digest + ); + assert_ne!( + changed.authority.content_digest, + held.authority.content_digest + ); + assert_eq!( + pool.stats().reused, + 2, + "different source bytes cannot hit the old artifact" + ); + assert_eq!(pool.stats().inserted, 2); + let mut offpath_request = worker_request_with_source("file.worker.offpath", 1_300_000, source); + offpath_request.snapshot.files[0].logical_path = "src/other.rs".to_owned(); + let (_, offpath) = retained_worker_artifact(&offpath_request, &pool); + assert_eq!(offpath.authority.logical_path, "src/other.rs"); + assert_eq!( + pool.stats().reused, + 2, + "identical bytes at another path cannot alias the artifact" + ); + assert_eq!(pool.stats().inserted, 3); + + // Retain a real source artifact while a complete production build borrows + // it; compare that build with an independent cold build of the same input. + pool.insert(key, &held); + let mut owner = + CodeIndexProductionOwnerV1::new(worker_config(), WorkerPublicationStore::default()) + .expect("borrowing owner") + .with_physical_artifact_pool(pool.clone()); + let reused = owner + .build_and_publish(request, &UninterruptibleCodeIndexControlV1) + .expect("production build borrows held artifact"); + assert_eq!(pool.stats().reused, 3); + assert_eq!(owner.retained_parse_stats().full_extractions, 0); + let cold = CodeIndexProductionOwnerV1::new(worker_config(), WorkerPublicationStore::default()) + .expect("cold owner") + .build_and_publish( + worker_request_with_source("file.worker.shared", 1_200_000, source), + &UninterruptibleCodeIndexControlV1, + ) + .expect("independent cold build"); + assert_eq!(reused.manifest_bytes(), cold.manifest_bytes()); + assert_eq!( + partitioned_seal(&decoded_generation(&reused)), + partitioned_seal(&decoded_generation(&cold)), + "a held artifact hit preserves every sealed byte and chunk identity", + ); + + drop((held, borrowed, foreign, changed, offpath)); + assert_eq!( + pool.stats().resident, + 0, + "the weak pool never extends an artifact's lifetime" + ); + pool.release_dead_entries(); + assert_eq!(pool.stats().resident, 0); +} + #[test] fn physical_artifact_reuse_rejects_a_stale_extractor_revision() { let source = b"mod inner { pub fn value() {} }\npub use inner::*;\n"; @@ -314,9 +1184,7 @@ fn physical_artifact_reuse_rejects_a_stale_extractor_revision() { &UninterruptibleCodeIndexControlV1, ) .expect("seed generation"); - let mut stale = generation.decoded().expect("cold build").files[0] - .as_ref() - .clone(); + let mut stale = decoded_generation(&generation).files[0].as_ref().clone(); stale.extraction.extractor_revision = ExtractorRevision::new("extractor.rust.v3").expect("historical extractor revision"); stale.extraction.parser_import_rows_digest = @@ -349,7 +1217,7 @@ fn physical_artifact_reuse_rejects_a_stale_extractor_revision() { let rebuilt = upgraded .build_and_publish(request, &UninterruptibleCodeIndexControlV1) .expect("stale pooled artifact is re-extracted"); - let rebuilt = rebuilt.decoded().expect("cold build"); + let rebuilt = decoded_generation(&rebuilt); assert_eq!( rebuilt.files[0].extraction.extractor_revision.as_str(), @@ -575,8 +1443,8 @@ fn concurrent_attribution_reads_share_success_without_blocking_cached_reads() { ), &UninterruptibleCodeIndexControlV1, ).unwrap(); - let generation = published.decoded().unwrap(); - let cloned_generation = (**generation).clone(); + let generation = decoded_generation(&published); + let cloned_generation = (*generation).clone(); assert_eq!( cloned_generation.test_attribution_read().provider_state, ProviderEvaluationStateV1::Indexing @@ -632,7 +1500,7 @@ fn failed_attribution_build_can_retry_and_resident_accounting_never_waits_for_it &UninterruptibleCodeIndexControlV1, ) .unwrap(); - let mut generation = (**published.decoded().unwrap()).clone(); + let mut generation = (*decoded_generation(&published)).clone(); let files = std::mem::take(&mut generation.snapshot.files); assert!( generation @@ -678,7 +1546,7 @@ fn attribution_preparation_checks_cancellation_and_can_retry() { "file.worker.attribution-cancel", 1_100_000, b"fn target() {}\n#[test] fn first() { target(); }\n#[test] fn second() { first(); }\n", ), &UninterruptibleCodeIndexControlV1).unwrap(); - let generation = published.decoded().unwrap(); + let generation = decoded_generation(&published); let control = CancelAfterChecks(std::sync::atomic::AtomicUsize::new(0)); assert!(matches!( generation.prepare_test_attribution(&control), diff --git a/crates/tracedecay-code-index/src/projection.rs b/crates/tracedecay-code-index/src/projection.rs index b7b65bb86f..1ea6ef0ef3 100644 --- a/crates/tracedecay-code-index/src/projection.rs +++ b/crates/tracedecay-code-index/src/projection.rs @@ -6,10 +6,14 @@ //! Store-owned transactions, active pointers, retries and scheduling remain //! outside this module. +use std::sync::Arc; + +use serde::ser::{Serialize, SerializeSeq, Serializer}; use thiserror::Error; use tracedecay_domain::{ - CodeGenerationId, ManifestDigest, ProjectionBatchReceiptV1, ProjectionBatchRequestV1, - ProjectionOperationV1, ProjectionOutcomeV1, ProjectionReplayReasonV1, + CodeChunkProjectionReceiptV1, CodeGenerationId, ContentDigest, ManifestDigest, + ProjectionBatchReceiptV1, ProjectionBatchRequestV1, ProjectionKeyV1, ProjectionReplayReasonV1, + projection_publication_digest_for_receipts, }; pub use super::receipts::{ @@ -17,10 +21,7 @@ pub use super::receipts::{ batch_proves_zero_work, build_batch_receipt, changeset_is_noop, decisions_for_noop, expected_publication_digest, expected_request_digest, verify_batch_receipt, }; -use super::receipts::{ - ProjectionRequestEvidenceV1, PublicationDigestTrustV1, build_batch_receipt_verified, - verify_batch_receipt_verified, -}; +use super::receipts::{applied_receipt, ordered_changes, validate_request, verify_receipt_rows}; /// Why a projection batch cannot cross the atomic publication boundary. #[derive(Clone, Debug, Error, PartialEq, Eq)] @@ -31,42 +32,87 @@ pub enum ProjectionPublicationErrorV1 { NotActivatable, } -/// A request and complete verified receipt ready for one store-owned atomic -/// publication transaction. -/// -/// Fields are private so a handoff cannot be assembled without running the -/// deterministic request/receipt verification gate. +/// Verified publication capability. Canonical rows replay from the immutable +/// request; only restored outputs that differ from the source need storage. +/// Store-owned transactions, active pointers and activation remain external. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProjectionPublicationHandoffV1 { + state: Arc, +} + +#[derive(Debug, PartialEq, Eq)] +struct ProjectionPublicationStateV1 { request: ProjectionBatchRequestV1, - receipt: ProjectionBatchReceiptV1, + publication_digest: ManifestDigest, + output_overrides: Vec<(usize, ContentDigest)>, } impl ProjectionPublicationHandoffV1 { pub fn request(&self) -> &ProjectionBatchRequestV1 { - &self.request + &self.state.request } - pub fn receipt(&self) -> &ProjectionBatchReceiptV1 { - &self.receipt + pub fn publication_digest(&self) -> &ManifestDigest { + &self.state.publication_digest } - pub fn publication_digest(&self) -> &ManifestDigest { - &self.receipt.publication_digest + pub fn request_digest(&self) -> &ManifestDigest { + &self.state.request.request_digest } pub fn source_generation(&self) -> &CodeGenerationId { - &self.receipt.source_generation + &self.state.request.changes.to_generation + } + + pub fn source_manifest_digest(&self) -> &ManifestDigest { + &self.state.request.changes.manifest_digest + } + + pub fn target_projection_key(&self) -> &ProjectionKeyV1 { + &self.state.request.target_projection_key + } + + pub fn receipt_count(&self) -> usize { + self.state.request.changes.added_or_changed.len() + self.state.request.changes.deleted.len() + } + + pub fn reused_count(&self) -> u64 { + self.state.request.changes.reused_count + } + + /// Replay the exact verified rows in canonical chunk order. Each row owns + /// its wire fields and can be consumed before the next row is allocated. + pub fn receipts(&self) -> impl Iterator + Clone + '_ { + self.rows().iter() + } + + /// Serialize the public receipt array without collecting it. This is also + /// the sequence passed to the domain's canonical publication digest. + pub fn receipt_rows(&self) -> impl Serialize + '_ { + self.rows() + } + + fn rows(&self) -> ProjectionReceiptRowsV1<'_> { + ProjectionReceiptRowsV1 { + request: &self.state.request, + output_overrides: &self.state.output_overrides, + } + } + + pub(crate) fn output_overrides(&self) -> &[(usize, ContentDigest)] { + &self.state.output_overrides + } + + pub(crate) fn shared_value_size(&self) -> usize { + std::mem::size_of::() } - /// Consume the validated handoff into the exact request and receipt a - /// store transaction persists and activates together. - pub fn into_parts(self) -> (ProjectionBatchRequestV1, ProjectionBatchReceiptV1) { - (self.request, self.receipt) + pub(crate) fn output_override_capacity(&self) -> usize { + self.state.output_overrides.capacity() } - /// Restore a durable handoff only after repeating the same receipt and - /// activation checks used by live projection publication. + /// Durable evidence must pass the full canonical verifier before it can + /// be reduced. Applied outputs need not equal source content digests. pub(crate) fn restore( request: ProjectionBatchRequestV1, receipt: ProjectionBatchReceiptV1, @@ -75,20 +121,64 @@ impl ProjectionPublicationHandoffV1 { if !batch_can_activate(&receipt) { return Err(ProjectionPublicationErrorV1::NotActivatable); } - Ok(Self { request, receipt }) + // The verifier requires Applied for every activatable affected row: + // the Reused complement has no rows. An Applied upsert has an output; + // a deletion has none. Thus only nondefault upsert outputs need storage. + // A fresh vector also prevents in-place iterator collection from + // retaining the expanded receipt vector's much larger allocation. + let mut output_overrides = Vec::new(); + for (index, row) in receipt.receipts.into_iter().enumerate() { + if let Some(output) = row.output_digest + && Some(&output) != row.current_chunk_digest.as_ref() + { + output_overrides.push((index, output)); + } + } + Ok(Self { + state: Arc::new(ProjectionPublicationStateV1 { + request, + publication_digest: receipt.publication_digest, + output_overrides, + }), + }) } } -/// Prepare the canonical receipt for one immutable changed-chunk request. -/// -/// This records prepared evidence, not durable activation. The store must still -/// atomically publish the returned handoff. Reused chunks are authenticated by -/// their partition count and digest, so a true no-op needs no per-chunk rows. -/// A projection-key replay must already expand every current chunk. -/// -/// The request digest and partition validation run once. The canonical builder -/// carries that evidence through receipt construction and verification; restored -/// receipts must recompute their publication digest at the durable boundary. +#[derive(Clone, Copy)] +struct ProjectionReceiptRowsV1<'a> { + request: &'a ProjectionBatchRequestV1, + output_overrides: &'a [(usize, ContentDigest)], +} + +impl<'a> ProjectionReceiptRowsV1<'a> { + fn iter(self) -> impl Iterator + Clone + 'a { + let mut overrides = self.output_overrides.iter().peekable(); + ordered_changes(&self.request.changes) + .enumerate() + .map(move |(index, change)| { + let mut receipt = applied_receipt(self.request, change); + if let Some((_, digest)) = overrides.next_if(|(position, _)| *position == index) { + receipt.output_digest = Some(digest.clone()); + } + receipt + }) + } +} + +impl Serialize for ProjectionReceiptRowsV1<'_> { + fn serialize(&self, serializer: S) -> Result { + let count = + self.request.changes.added_or_changed.len() + self.request.changes.deleted.len(); + let mut sequence = serializer.serialize_seq(Some(count))?; + for receipt in self.iter() { + sequence.serialize_element(&receipt)?; + } + sequence.end() + } +} + +/// Prepare one immutable changed-chunk request using the same row verifier and +/// publication digest as expanded evidence. The store still owns activation. #[tracing::instrument( name = "code_index.build.project_publication", level = "trace", @@ -97,54 +187,35 @@ impl ProjectionPublicationHandoffV1 { pub fn project_for_publication( request: ProjectionBatchRequestV1, ) -> Result { - let (request, request_digest) = expand_projection_key_replay(request)?; - let evidence = ProjectionRequestEvidenceV1::recorded(request_digest, &request.changes); - let mut decisions = request - .changes - .added_or_changed - .iter() - .chain(&request.changes.deleted) - .map(|change| ChunkProjectionDecisionV1 { - chunk_id: change.chunk_id.clone(), - prior_chunk_digest: change.prior_digest.clone(), - current_chunk_digest: change.current_digest.clone(), - operation: match (&change.prior_digest, &change.current_digest) { - (_, None) => ProjectionOperationV1::Deleted, - (None, Some(_)) => ProjectionOperationV1::Added, - (Some(_), Some(_)) => ProjectionOperationV1::Updated, - }, - outcome: ProjectionOutcomeV1::Applied, - output_digest: change.current_digest.clone(), - }) - .collect::>(); - decisions.sort_by(|left, right| left.chunk_id.cmp(&right.chunk_id)); - let receipt = build_batch_receipt_verified(&request, &evidence, &decisions)?; - verify_batch_receipt_verified( - &request, - &evidence, - &receipt, - PublicationDigestTrustV1::SealedInThisChain, - )?; - if !batch_can_activate(&receipt) { - return Err(ProjectionPublicationErrorV1::NotActivatable); - } - Ok(ProjectionPublicationHandoffV1 { request, receipt }) + validate_request(&request)?; + validate_projection_key_replay(&request)?; + let rows = ProjectionReceiptRowsV1 { + request: &request, + output_overrides: &[], + }; + verify_receipt_rows(&request, rows.iter())?; + let publication_digest = projection_publication_digest_for_receipts( + &request.target_projection_key, + &request.request_digest, + &request.changes.to_generation, + &request.changes.manifest_digest, + &rows, + request.changes.reused_count, + ) + .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?; + Ok(ProjectionPublicationHandoffV1 { + state: Arc::new(ProjectionPublicationStateV1 { + request, + publication_digest, + output_overrides: Vec::new(), + }), + }) } -/// Verify the incoming request and expand a projection-key replay, returning -/// the request alongside its recomputed canonical digest. -fn expand_projection_key_replay( - request: ProjectionBatchRequestV1, -) -> Result<(ProjectionBatchRequestV1, ManifestDigest), ProjectionReceiptErrorV1> { - let expected_request = expected_request_digest(&request) - .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?; - if request.request_digest != expected_request { - return Err(ProjectionReceiptErrorV1::DigestMismatch); - } - request - .changes - .validate() - .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?; +/// Live projection-key changes require an explicitly expanded replay. +fn validate_projection_key_replay( + request: &ProjectionBatchRequestV1, +) -> Result<(), ProjectionReceiptErrorV1> { if request.previous_projection_key.is_none() { if request.replay_reason != ProjectionReplayReasonV1::InitialProjection { return Err(ProjectionReceiptErrorV1::Contract( @@ -152,10 +223,10 @@ fn expand_projection_key_replay( .to_owned(), )); } - return Ok((request, expected_request)); + return Ok(()); } if request.previous_projection_key.as_ref() == Some(&request.target_projection_key) { - return Ok((request, expected_request)); + return Ok(()); } if request.replay_reason != ProjectionReplayReasonV1::ProjectionProfileChange { return Err(ProjectionReceiptErrorV1::Contract( @@ -169,7 +240,7 @@ fn expand_projection_key_replay( .to_owned(), )); } - Ok((request, expected_request)) + Ok(()) } #[cfg(test)] diff --git a/crates/tracedecay-code-index/src/projection/tests.rs b/crates/tracedecay-code-index/src/projection/tests.rs index ea67eeafe3..49aea17687 100644 --- a/crates/tracedecay-code-index/src/projection/tests.rs +++ b/crates/tracedecay-code-index/src/projection/tests.rs @@ -7,8 +7,8 @@ use super::{ }; use tracedecay_domain::{ ChangedCodeChunkSetV1, ChangedCodeChunkV1, CodeGenerationId, CodeSearchChunkId, ContentDigest, - ManifestDigest, ProjectionBatchRequestV1, ProjectionKeyV1, ProjectionKindV1, - ProjectionOperationV1, ProjectionOutcomeV1, ProjectionReplayReasonV1, + ManifestDigest, ProjectionBatchReceiptV1, ProjectionBatchRequestV1, ProjectionKeyV1, + ProjectionKindV1, ProjectionOperationV1, ProjectionOutcomeV1, ProjectionReplayReasonV1, }; use tracedecay_domain::test_fixtures::id; @@ -85,6 +85,18 @@ fn decisions() -> Vec { }] } +fn expanded_receipt(handoff: &ProjectionPublicationHandoffV1) -> ProjectionBatchReceiptV1 { + ProjectionBatchReceiptV1 { + target_projection_key: handoff.target_projection_key().clone(), + request_digest: handoff.request_digest().clone(), + source_generation: handoff.source_generation().clone(), + source_manifest_digest: handoff.source_manifest_digest().clone(), + receipts: handoff.receipts().collect(), + reused_count: handoff.reused_count(), + publication_digest: handoff.publication_digest().clone(), + } +} + #[test] fn request_without_a_prior_key_requires_the_initial_replay_reason() { let mut changes = ChangedCodeChunkSetV1 { @@ -142,8 +154,8 @@ fn source_edit_receipt_authenticates_reuse_without_per_chunk_rows() { let request = request(); let expected = build_batch_receipt(&request, &decisions()).expect("receipt builds"); let handoff = project_for_publication(request.clone()).expect("publication prepared"); - let receipt = handoff.receipt(); - assert_eq!(receipt, &expected); + let receipt = expanded_receipt(&handoff); + assert_eq!(receipt, expected); assert_eq!( ProjectionPublicationHandoffV1::restore(request.clone(), receipt.clone()) .expect("durable handoff restores"), @@ -153,7 +165,7 @@ fn source_edit_receipt_authenticates_reuse_without_per_chunk_rows() { assert_eq!(receipt.reused_count, 1); assert_eq!(receipt.receipts.len(), 1); assert_eq!(receipt.receipts[0].chunk_id, chunk("updated")); - verify_batch_receipt(&request, receipt).expect("compact reuse remains verified"); + verify_batch_receipt(&request, &receipt).expect("compact reuse remains verified"); } #[test] @@ -236,8 +248,8 @@ fn canonical_receipts_preserve_additions_deletions_and_compact_noops() { changes.manifest_digest = changes.compute_digest().expect("changeset digest"); let request = request_for(changes); let handoff = project_for_publication(request.clone()).expect("mixed publication"); - let receipt = handoff.receipt(); - verify_batch_receipt(&request, receipt).expect("canonical receipt verifies"); + let receipt = expanded_receipt(&handoff); + verify_batch_receipt(&request, &receipt).expect("canonical receipt verifies"); let operations: Vec<_> = receipt .receipts .iter() @@ -272,8 +284,8 @@ fn canonical_receipts_preserve_additions_deletions_and_compact_noops() { noop.manifest_digest = noop.compute_digest().expect("noop digest"); let request = request_for(noop); let first = project_for_publication(request.clone()).expect("noop publication"); - assert_eq!(first.receipt().reused_count, 1); - assert!(first.receipt().receipts.is_empty()); + assert_eq!(first.reused_count(), 1); + assert!(first.receipt_count() == 0); assert_eq!( project_for_publication(request).expect("repeat noop publication"), first, @@ -300,9 +312,207 @@ fn projection_key_replay_requires_expanded_chunks_and_binds_the_new_key() { request.request_digest = expected_request_digest(&request).expect("expanded request digest"); let handoff = project_for_publication(request.clone()).expect("expanded replay"); assert_eq!( - handoff.receipt().target_projection_key, - request.target_projection_key + handoff.target_projection_key(), + &request.target_projection_key + ); + assert_eq!(handoff.receipt_count(), 1); + verify_batch_receipt(&request, &expanded_receipt(&handoff)).expect("expanded replay verifies"); +} + +/// Independent sorted-key JSON encodings of the shipped tuple and object +/// shapes pin both the expanded and replayed receipt identities. +#[test] +fn replay_and_expanded_evidence_preserve_canonical_golden_digests() { + let request = request(); + assert_eq!( + request.changes.manifest_digest.as_str(), + "sha256:7f5affa869319ff9fb26b465b7984f30e1aef8752e9e4bf1e3adf2d95884c61d" + ); + assert_eq!( + request.request_digest.as_str(), + "sha256:c3d7b067e610231dccac800666c91bf00e85f601624733a6ab4ddb8452a5dee1" + ); + assert_eq!( + tracedecay_domain::code_source_parent_delta_digest(Some(&digest('d')), &request.changes) + .expect("source commitment") + .as_str(), + "sha256:6dc071a64c5b1bbce8e98b35d8bfdd6ac062353ff2c11893e11394620cb4ee57", + ); + let expanded = build_batch_receipt(&request, &decisions()).expect("expanded receipt"); + assert_eq!( + expanded.publication_digest.as_str(), + "sha256:394e937efe9c192a732ffcba79c5cf97145e4639803373ea6cd8ac3c8949b898" + ); + let handoff = project_for_publication(request).expect("replayed receipt"); + assert_eq!(handoff.publication_digest(), &expanded.publication_digest); + assert_eq!(expanded_receipt(&handoff), expanded); + assert_eq!( + serde_json::to_vec(&handoff.receipt_rows()).expect("serialize replay"), + serde_json::to_vec(&expanded.receipts).expect("serialize expanded rows"), + ); + assert!(handoff.output_overrides().is_empty()); + assert_eq!(handoff.output_override_capacity(), 0); + let restored = ProjectionPublicationHandoffV1::restore(handoff.request().clone(), expanded) + .expect("restore default rows"); + assert!(restored.output_overrides().is_empty()); + assert_eq!(restored.output_override_capacity(), 0); +} + +#[test] +fn restored_nondefault_outputs_replay_without_normalizing_the_publication() { + let request = request(); + let mut decisions = decisions(); + decisions[0].output_digest = Some(digest('f')); + let expanded = build_batch_receipt(&request, &decisions).expect("independent output"); + let restored = ProjectionPublicationHandoffV1::restore(request, expanded.clone()) + .expect("valid output restores"); + assert_eq!(restored.output_overrides(), &[(0, digest('f'))]); + assert_eq!(expanded_receipt(&restored), expanded); + assert_eq!( + serde_json::to_vec(&restored.receipt_rows()).expect("restored replay"), + serde_json::to_vec(&expanded.receipts).expect("expanded replay"), + ); + verify_batch_receipt(restored.request(), &expanded_receipt(&restored)) + .expect("restore remains verified"); +} + +#[test] +fn restored_evidence_rejects_row_coverage_order_and_identity_corruption() { + let mut changes = changeset(); + changes.deleted.push(ChangedCodeChunkV1 { + chunk_id: chunk("deleted"), + prior_digest: Some(digest('f')), + current_digest: None, + }); + changes.manifest_digest = changes.compute_digest().expect("mixed seal"); + let request = request_for(changes); + let valid = + expanded_receipt(&project_for_publication(request.clone()).expect("mixed publication")); + let restore = |mut receipt: ProjectionBatchReceiptV1| { + // Authenticate the corrupted payload itself so a row-rule denial is + // independent of the publication digest check. + receipt.publication_digest = + super::expected_publication_digest(&receipt).expect("canonical digest"); + ProjectionPublicationHandoffV1::restore(request.clone(), receipt) + }; + let mut missing = valid.clone(); + missing.receipts.remove(0); + assert_eq!( + restore(missing), + Err(ProjectionPublicationErrorV1::Receipt( + ProjectionReceiptErrorV1::MissingChunkReceipt(chunk("deleted")) + )) + ); + let mut duplicate = valid.clone(); + duplicate.receipts.insert(0, duplicate.receipts[0].clone()); + assert_eq!( + restore(duplicate), + Err(ProjectionPublicationErrorV1::Receipt( + ProjectionReceiptErrorV1::NonCanonicalReceiptOrder + )) + ); + let mut reversed = valid.clone(); + reversed.receipts.reverse(); + assert_eq!( + restore(reversed), + Err(ProjectionPublicationErrorV1::Receipt( + ProjectionReceiptErrorV1::NonCanonicalReceiptOrder + )) + ); + let mut extra = valid.clone(); + extra.receipts[0].chunk_id = chunk("alien"); + assert_eq!( + restore(extra), + Err(ProjectionPublicationErrorV1::Receipt( + ProjectionReceiptErrorV1::ExtraChunkReceipt(chunk("alien")) + )) + ); + let mut foreign_generation = valid.clone(); + foreign_generation.receipts[0].source_generation = generation(3); + assert_eq!( + restore(foreign_generation), + Err(ProjectionPublicationErrorV1::Receipt( + ProjectionReceiptErrorV1::CrossGenerationReceipt(chunk("deleted")) + )) + ); + let mut key = valid.clone(); + key.receipts[0].projection_key.kind = ProjectionKindV1::Graph; + assert_eq!( + restore(key), + Err(ProjectionPublicationErrorV1::Receipt( + ProjectionReceiptErrorV1::WrongProjectionKey + )) + ); + let mut operation = valid.clone(); + operation.receipts[0].operation = ProjectionOperationV1::Added; + assert_eq!( + restore(operation), + Err(ProjectionPublicationErrorV1::Receipt( + ProjectionReceiptErrorV1::InconsistentOperation(chunk("deleted")) + )) + ); + let mut output = valid.clone(); + output.receipts[0].output_digest = Some(digest('d')); + assert_eq!( + restore(output), + Err(ProjectionPublicationErrorV1::Receipt( + ProjectionReceiptErrorV1::InconsistentOperation(chunk("deleted")) + )) + ); + let mut reuse = valid.clone(); + reuse.receipts[0].outcome = ProjectionOutcomeV1::Reused; + assert_eq!( + restore(reuse), + Err(ProjectionPublicationErrorV1::Receipt( + ProjectionReceiptErrorV1::InconsistentOperation(chunk("deleted")) + )) + ); + let mut reused_count = valid.clone(); + reused_count.reused_count += 1; + assert_eq!( + restore(reused_count), + Err(ProjectionPublicationErrorV1::Receipt( + ProjectionReceiptErrorV1::DigestMismatch + )) + ); + let mut skipped = valid; + skipped.receipts[0].outcome = ProjectionOutcomeV1::Skipped { + reason: "not projected".to_owned(), + }; + assert_eq!( + restore(skipped), + Err(ProjectionPublicationErrorV1::NotActivatable) + ); +} + +#[test] +fn replay_iterators_clone_their_exact_remaining_position() { + let mut changes = changeset(); + changes.deleted.push(ChangedCodeChunkV1 { + chunk_id: chunk("deleted"), + prior_digest: Some(digest('f')), + current_digest: None, + }); + changes.manifest_digest = changes.compute_digest().expect("mixed seal"); + let handoff = project_for_publication(request_for(changes)).expect("mixed handoff"); + let mut rows = handoff.receipts(); + assert_eq!(rows.next().expect("first row").chunk_id, chunk("deleted")); + let remaining = rows.clone().collect::>(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].chunk_id, chunk("updated")); + assert_eq!(rows.collect::>(), remaining); + assert_eq!(handoff.receipts().count(), handoff.receipt_count()); +} + +#[test] +fn cloned_publications_share_the_immutable_request_and_replay_identical_rows() { + let publication = project_for_publication(request()).expect("verified publication"); + let cloned = publication.clone(); + assert!(std::ptr::eq(publication.request(), cloned.request())); + assert_eq!( + publication.receipts().collect::>(), + cloned.receipts().collect::>() ); - assert_eq!(handoff.receipt().receipts.len(), 1); - verify_batch_receipt(&request, handoff.receipt()).expect("expanded replay verifies"); + drop(publication); + assert!(!cloned.receipts().collect::>().is_empty()); } diff --git a/crates/tracedecay-code-index/src/receipts.rs b/crates/tracedecay-code-index/src/receipts.rs index 9768a67208..8045d2a0cf 100644 --- a/crates/tracedecay-code-index/src/receipts.rs +++ b/crates/tracedecay-code-index/src/receipts.rs @@ -20,18 +20,17 @@ //! and proves zero work. Failed or skipped receipts remain inspectable but //! cannot activate a projection generation. -use std::collections::{BTreeMap, BTreeSet}; +use std::borrow::Borrow; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracedecay_domain::{ - ChangedCodeChunkSetV1, CodeChunkProjectionReceiptV1, CodeSearchChunkId, ContentDigest, - DomainError, ManifestDigest, ProjectionBatchReceiptV1, ProjectionBatchRequestV1, + ChangedCodeChunkSetV1, ChangedCodeChunkV1, CodeChunkProjectionReceiptV1, CodeSearchChunkId, + ContentDigest, DomainError, ManifestDigest, ProjectionBatchReceiptV1, ProjectionBatchRequestV1, ProjectionOperationV1, ProjectionOutcomeV1, ProjectionReplayReasonV1, canonical_sha256, + projection_publication_digest_for_receipts, }; -use super::generations::placeholder_digest; - /// Domain separator for the canonical projection-batch-request digest. pub const PROJECTION_REQUEST_SEPARATOR: &str = "tracedecay.projection-batch-request.v1"; @@ -79,14 +78,6 @@ pub struct ChunkProjectionDecisionV1 { pub output_digest: Option, } -/// The partition of the changed-chunk set a chunk belongs to. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Partition { - AddedOrChanged, - Deleted, - Reused, -} - /// The canonical request digest: every request field except the digest /// itself, under the request domain separator. pub fn expected_request_digest( @@ -144,222 +135,218 @@ pub fn decisions_for_noop(_changes: &ChangedCodeChunkSetV1) -> Vec, -} - -impl ProjectionRequestEvidenceV1 { - /// Record evidence for a request whose digest was just recomputed and - /// whose changed-chunk set was just validated by the caller. - pub(crate) fn recorded( - request_digest: ManifestDigest, - changes: &ChangedCodeChunkSetV1, - ) -> Self { - Self { - request_digest, - partitions: index_partitions(changes), +/// Request authentication shared by the expanded and replayed receipt paths. +pub(crate) fn validate_request( + request: &ProjectionBatchRequestV1, +) -> Result<(), ProjectionReceiptErrorV1> { + let expected = expected_request_digest(request) + .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?; + if request.request_digest != expected { + return Err(ProjectionReceiptErrorV1::DigestMismatch); + } + request + .changes + .validate() + .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?; + let projection_changed = + request.previous_projection_key.as_ref() != Some(&request.target_projection_key); + if projection_changed && request.changes.reused_count > 0 { + if request.replay_reason == ProjectionReplayReasonV1::ProjectionProfileChange { + return Err(ProjectionReceiptErrorV1::Contract( + "projection profile change must arrive pre-expanded without a reused complement" + .to_owned(), + )); } + return Err(ProjectionReceiptErrorV1::ProjectionKeyReplayRequiresAppliedWork); } + Ok(()) } -/// Whether a batch receipt's publication digest still has to be recomputed. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum PublicationDigestTrustV1 { - /// The receipt crossed a trust boundary, such as durable - /// storage, or any other caller, so its self-declared publication digest - /// proves nothing until it is recomputed from the receipt's own fields. - Unverified, - /// The receipt was sealed by [`build_batch_receipt`] earlier in this same - /// call chain and has not been touched since, so its publication digest is - /// the value that recomputation would produce. - SealedInThisChain, +/// Merge the already canonical partitions without copying their identities or +/// digests. The request validator owns ordering and partition disjointness. +#[derive(Clone)] +pub(crate) struct OrderedProjectionChangesV1<'a> { + added: &'a [ChangedCodeChunkV1], + deleted: &'a [ChangedCodeChunkV1], } -/// Build the complete batch receipt for one projection request from the -/// projector's affected-chunk decisions. Work-free reused chunks are already -/// authenticated by the request digest and contribute only to `reused_count`. -/// Profile replay that re-embeds the reused partition requires Updated/Applied -/// rows for those chunks and leaves `reused_count` at zero. The receipts are -/// canonically ordered and the publication digest seals the batch. -/// Construction is deterministic, so identical requests and decisions yield -/// identical receipts. -pub fn build_batch_receipt( - request: &ProjectionBatchRequestV1, - decisions: &[ChunkProjectionDecisionV1], -) -> Result { - build_batch_receipt_with(request, None, decisions) +pub(crate) fn ordered_changes(changes: &ChangedCodeChunkSetV1) -> OrderedProjectionChangesV1<'_> { + OrderedProjectionChangesV1 { + added: &changes.added_or_changed, + deleted: &changes.deleted, + } } -/// [`build_batch_receipt`] for a request this chain already verified. -pub(crate) fn build_batch_receipt_verified( - request: &ProjectionBatchRequestV1, - evidence: &ProjectionRequestEvidenceV1, - decisions: &[ChunkProjectionDecisionV1], -) -> Result { - build_batch_receipt_with(request, Some(evidence), decisions) -} +impl<'a> Iterator for OrderedProjectionChangesV1<'a> { + type Item = &'a ChangedCodeChunkV1; -fn build_batch_receipt_with( - request: &ProjectionBatchRequestV1, - evidence: Option<&ProjectionRequestEvidenceV1>, - decisions: &[ChunkProjectionDecisionV1], -) -> Result { - let expected_request = match evidence { - Some(evidence) => evidence.request_digest.clone(), - None => expected_request_digest(request) - .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?, - }; - if request.request_digest != expected_request { - return Err(ProjectionReceiptErrorV1::DigestMismatch); + fn next(&mut self) -> Option { + let partition = match (self.added.first(), self.deleted.first()) { + (Some(added), Some(deleted)) if added.chunk_id < deleted.chunk_id => &mut self.added, + (Some(_), Some(_)) | (None, Some(_)) => &mut self.deleted, + (Some(_), None) => &mut self.added, + (None, None) => return None, + }; + let (first, rest) = (*partition).split_first()?; + *partition = rest; + Some(first) } - let reembed_reused = reembeds_reused_chunks(request)?; - let recomputed_partitions; - let partitions = match evidence { - Some(evidence) => &evidence.partitions, - None => { - recomputed_partitions = partitions_of(&request.changes)?; - &recomputed_partitions - } - }; - let mut seen = BTreeSet::new(); - let mut receipts = Vec::with_capacity(decisions.len()); - for decision in decisions { - let Some((partition, change)) = partitions.get(&decision.chunk_id) else { - return Err(ProjectionReceiptErrorV1::ExtraChunkReceipt( - decision.chunk_id.clone(), - )); - }; - if !seen.insert(decision.chunk_id.clone()) { - return Err(ProjectionReceiptErrorV1::DuplicateChunkReceipt( - decision.chunk_id.clone(), - )); - } - check_decision(*partition, change, decision, reembed_reused)?; - receipts.push(CodeChunkProjectionReceiptV1 { - projection_key: request.target_projection_key.clone(), - request_digest: request.request_digest.clone(), - prior_generation: request.changes.from_generation.clone(), - source_generation: request.changes.to_generation.clone(), - source_manifest_digest: request.changes.manifest_digest.clone(), - chunk_id: decision.chunk_id.clone(), - prior_chunk_digest: decision.prior_chunk_digest.clone(), - current_chunk_digest: decision.current_chunk_digest.clone(), - operation: decision.operation, - outcome: decision.outcome.clone(), - output_digest: decision.output_digest.clone(), - }); + fn size_hint(&self) -> (usize, Option) { + let len = self.added.len() + self.deleted.len(); + (len, Some(len)) } - for chunk_id in partitions.keys() { - if !seen.contains(chunk_id) { - return Err(ProjectionReceiptErrorV1::MissingChunkReceipt( - chunk_id.clone(), - )); - } +} + +impl ExactSizeIterator for OrderedProjectionChangesV1<'_> {} + +/// Operation and default output are derived from the canonical request row. +pub(crate) fn applied_operation(change: &ChangedCodeChunkV1) -> ProjectionOperationV1 { + match (&change.prior_digest, &change.current_digest) { + (_, None) => ProjectionOperationV1::Deleted, + (None, Some(_)) => ProjectionOperationV1::Added, + (Some(_), Some(_)) => ProjectionOperationV1::Updated, } - receipts.sort_by(|left, right| left.chunk_id.cmp(&right.chunk_id)); +} - let reused_count = work_free_reused_count(&request.changes, reembed_reused); - let mut batch = ProjectionBatchReceiptV1 { - target_projection_key: request.target_projection_key.clone(), +fn receipt_from_decision( + request: &ProjectionBatchRequestV1, + decision: ChunkProjectionDecisionV1, +) -> CodeChunkProjectionReceiptV1 { + CodeChunkProjectionReceiptV1 { + projection_key: request.target_projection_key.clone(), request_digest: request.request_digest.clone(), + prior_generation: request.changes.from_generation.clone(), source_generation: request.changes.to_generation.clone(), source_manifest_digest: request.changes.manifest_digest.clone(), - receipts, - reused_count, - publication_digest: placeholder_digest(), - }; - batch.publication_digest = expected_publication_digest(&batch) - .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?; - Ok(batch) + chunk_id: decision.chunk_id, + prior_chunk_digest: decision.prior_chunk_digest, + current_chunk_digest: decision.current_chunk_digest, + operation: decision.operation, + outcome: decision.outcome, + output_digest: decision.output_digest, + } } -/// Verify a batch receipt against its request: request and publication -/// digests recompute, the receipt set covers the request exactly (no -/// missing, extra, or duplicate receipts), every receipt carries the -/// request's projection key and generation watermarks, and every operation, -/// outcome, and digest is consistent with the request partitions. -pub fn verify_batch_receipt( +pub(crate) fn applied_receipt( request: &ProjectionBatchRequestV1, - batch: &ProjectionBatchReceiptV1, -) -> Result<(), ProjectionReceiptErrorV1> { - verify_batch_receipt_with(request, None, batch, PublicationDigestTrustV1::Unverified) + change: &ChangedCodeChunkV1, +) -> CodeChunkProjectionReceiptV1 { + receipt_from_decision( + request, + ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: change.current_digest.clone(), + operation: applied_operation(change), + outcome: ProjectionOutcomeV1::Applied, + output_digest: change.current_digest.clone(), + }, + ) } -/// [`verify_batch_receipt`] for a request this chain already verified, and a -/// receipt whose publication digest may already be known good. -pub(crate) fn verify_batch_receipt_verified( +/// Build expanded public evidence for callers that need the public DTO. Live +/// publication instead replays these same rows without retaining the vector. +pub fn build_batch_receipt( request: &ProjectionBatchRequestV1, - evidence: &ProjectionRequestEvidenceV1, - batch: &ProjectionBatchReceiptV1, - publication: PublicationDigestTrustV1, -) -> Result<(), ProjectionReceiptErrorV1> { - verify_batch_receipt_with(request, Some(evidence), batch, publication) + decisions: &[ChunkProjectionDecisionV1], +) -> Result { + validate_request(request)?; + let mut receipts = decisions + .iter() + .cloned() + .map(|decision| receipt_from_decision(request, decision)) + .collect::>(); + receipts.sort_by(|left, right| left.chunk_id.cmp(&right.chunk_id)); + if let Some(pair) = receipts + .windows(2) + .find(|pair| pair[0].chunk_id == pair[1].chunk_id) + { + return Err(ProjectionReceiptErrorV1::DuplicateChunkReceipt( + pair[0].chunk_id.clone(), + )); + } + verify_receipt_rows(request, receipts.iter())?; + let publication_digest = projection_publication_digest_for_receipts( + &request.target_projection_key, + &request.request_digest, + &request.changes.to_generation, + &request.changes.manifest_digest, + &receipts, + request.changes.reused_count, + ) + .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?; + Ok(ProjectionBatchReceiptV1 { + target_projection_key: request.target_projection_key.clone(), + request_digest: request.request_digest.clone(), + source_generation: request.changes.to_generation.clone(), + source_manifest_digest: request.changes.manifest_digest.clone(), + receipts, + reused_count: request.changes.reused_count, + publication_digest, + }) } -fn verify_batch_receipt_with( +/// Verify every stored batch header, row and digest before accepting evidence +/// across a trust boundary. Prepared evidence carries no implicit trust here. +pub fn verify_batch_receipt( request: &ProjectionBatchRequestV1, - evidence: Option<&ProjectionRequestEvidenceV1>, batch: &ProjectionBatchReceiptV1, - publication: PublicationDigestTrustV1, ) -> Result<(), ProjectionReceiptErrorV1> { - let expected_request = match evidence { - Some(evidence) => evidence.request_digest.clone(), - None => expected_request_digest(request) - .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?, - }; - // The batch is never trusted about the request it answers, even when the - // request digest itself was recomputed earlier in this chain. - if request.request_digest != expected_request || batch.request_digest != expected_request { + validate_request(request)?; + if batch.request_digest != request.request_digest { return Err(ProjectionReceiptErrorV1::DigestMismatch); } - let reembed_reused = reembeds_reused_chunks(request)?; if batch.target_projection_key != request.target_projection_key { return Err(ProjectionReceiptErrorV1::WrongProjectionKey); } if batch.source_generation != request.changes.to_generation || batch.source_manifest_digest != request.changes.manifest_digest { - return Err(ProjectionReceiptErrorV1::CrossGenerationReceipt( - batch.receipts.first().map_or_else( - || { - CodeSearchChunkId::new("chunk.v1.empty-batch") - .expect("canonical chunk identity") - }, - |receipt| receipt.chunk_id.clone(), - ), - )); + let chunk_id = match batch.receipts.first() { + Some(receipt) => receipt.chunk_id.clone(), + None => CodeSearchChunkId::new("chunk.v1.empty-batch") + .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?, + }; + return Err(ProjectionReceiptErrorV1::CrossGenerationReceipt(chunk_id)); } - if batch - .receipts - .windows(2) - .any(|pair| pair[0].chunk_id >= pair[1].chunk_id) - { - return Err(ProjectionReceiptErrorV1::NonCanonicalReceiptOrder); + verify_receipt_rows(request, batch.receipts.iter())?; + if batch.reused_count != request.changes.reused_count { + return Err(ProjectionReceiptErrorV1::DigestMismatch); } + let expected = expected_publication_digest(batch) + .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?; + if batch.publication_digest != expected { + return Err(ProjectionReceiptErrorV1::DigestMismatch); + } + Ok(()) +} - let recomputed_partitions; - let partitions = match evidence { - Some(evidence) => &evidence.partitions, - None => { - recomputed_partitions = partitions_of(&request.changes)?; - &recomputed_partitions +/// The single row verifier for expanded receipts and live deterministic +/// replay. It assumes the immutable request has passed `validate_request`. +/// Only one receipt and one request row need be live at a time. +pub(crate) fn verify_receipt_rows( + request: &ProjectionBatchRequestV1, + receipts: I, +) -> Result<(), ProjectionReceiptErrorV1> +where + I: Iterator + Clone, + I::Item: Borrow, +{ + let mut previous = None; + for row in receipts.clone() { + let receipt = row.borrow(); + if previous + .as_ref() + .is_some_and(|prior| prior >= &receipt.chunk_id) + { + return Err(ProjectionReceiptErrorV1::NonCanonicalReceiptOrder); } - }; - let mut seen = BTreeSet::new(); - for receipt in &batch.receipts { + previous = Some(receipt.chunk_id.clone()); + } + let mut changes = ordered_changes(&request.changes); + for row in receipts { + let receipt = row.borrow(); if receipt.projection_key != request.target_projection_key { return Err(ProjectionReceiptErrorV1::WrongProjectionKey); } @@ -372,190 +359,59 @@ fn verify_batch_receipt_with( receipt.chunk_id.clone(), )); } - let Some((partition, change)) = partitions.get(&receipt.chunk_id) else { + let Some(change) = changes.next() else { return Err(ProjectionReceiptErrorV1::ExtraChunkReceipt( receipt.chunk_id.clone(), )); }; - if !seen.insert(receipt.chunk_id.clone()) { - return Err(ProjectionReceiptErrorV1::DuplicateChunkReceipt( - receipt.chunk_id.clone(), - )); - } - let decision = ChunkProjectionDecisionV1 { - chunk_id: receipt.chunk_id.clone(), - prior_chunk_digest: receipt.prior_chunk_digest.clone(), - current_chunk_digest: receipt.current_chunk_digest.clone(), - operation: receipt.operation, - outcome: receipt.outcome.clone(), - output_digest: receipt.output_digest.clone(), - }; - check_decision(*partition, change, &decision, reembed_reused)?; - } - for chunk_id in partitions.keys() { - if !seen.contains(chunk_id) { - return Err(ProjectionReceiptErrorV1::MissingChunkReceipt( - chunk_id.clone(), - )); - } - } - - let reused_count = work_free_reused_count(&request.changes, reembed_reused); - if batch.reused_count != reused_count { - return Err(ProjectionReceiptErrorV1::DigestMismatch); - } - if publication == PublicationDigestTrustV1::Unverified { - let expected_publication = expected_publication_digest(batch) - .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?; - if batch.publication_digest != expected_publication { - return Err(ProjectionReceiptErrorV1::DigestMismatch); + if receipt.chunk_id != change.chunk_id { + let belongs = request + .changes + .added_or_changed + .binary_search_by(|change| change.chunk_id.cmp(&receipt.chunk_id)) + .is_ok() + || request + .changes + .deleted + .binary_search_by(|change| change.chunk_id.cmp(&receipt.chunk_id)) + .is_ok(); + return Err(if belongs { + ProjectionReceiptErrorV1::MissingChunkReceipt(change.chunk_id.clone()) + } else { + ProjectionReceiptErrorV1::ExtraChunkReceipt(receipt.chunk_id.clone()) + }); } + check_receipt(change, receipt)?; } - Ok(()) -} - -fn reembeds_reused_chunks( - request: &ProjectionBatchRequestV1, -) -> Result { - let projection_changed = - request.previous_projection_key.as_ref() != Some(&request.target_projection_key); - if projection_changed - && request.changes.reused_count > 0 - && request.replay_reason != ProjectionReplayReasonV1::ProjectionProfileChange - { - return Err(ProjectionReceiptErrorV1::ProjectionKeyReplayRequiresAppliedWork); - } - if projection_changed - && request.changes.reused_count > 0 - && request.replay_reason == ProjectionReplayReasonV1::ProjectionProfileChange - { - return Err(ProjectionReceiptErrorV1::Contract( - "projection profile change must arrive pre-expanded without a reused complement" - .to_owned(), - )); - } - Ok(false) -} - -/// Count work-free reused chunks from the request complement. Profile replay -/// arrives pre-expanded, so `reused_count` is already zero on that path. -fn work_free_reused_count(changes: &ChangedCodeChunkSetV1, reembed_reused: bool) -> u64 { - if reembed_reused { - 0 - } else { - changes.reused_count - } -} - -/// Index the changed-chunk set's partitions by chunk identity, validating -/// the set first (canonical order, partition shape, and manifest digest are -/// owned by the domain contract). -fn partitions_of( - changes: &ChangedCodeChunkSetV1, -) -> Result, ProjectionReceiptErrorV1> { - changes - .validate() - .map_err(|error| ProjectionReceiptErrorV1::Contract(error.to_string()))?; - Ok(index_partitions(changes)) -} - -/// Index an already-validated changed-chunk set by chunk identity. -/// -/// Reused chunks are a digest-authenticated complement and are not indexed -/// as per-chunk partitions. `ChangedCodeChunkSetV1::validate` recomputes the -/// whole set's manifest digest, so it runs once per call chain and the index -/// is threaded onward. -fn index_partitions( - changes: &ChangedCodeChunkSetV1, -) -> BTreeMap { - let mut partitions = BTreeMap::new(); - for change in &changes.added_or_changed { - partitions.insert( - change.chunk_id.clone(), - ( - Partition::AddedOrChanged, - DigestPair { - prior: change.prior_digest.clone(), - current: change.current_digest.clone(), - }, - ), - ); - } - for change in &changes.deleted { - partitions.insert( + if let Some(change) = changes.next() { + return Err(ProjectionReceiptErrorV1::MissingChunkReceipt( change.chunk_id.clone(), - ( - Partition::Deleted, - DigestPair { - prior: change.prior_digest.clone(), - current: change.current_digest.clone(), - }, - ), - ); + )); } - partitions -} - -/// The digests a request partition declares for one chunk. -#[derive(Clone, Debug)] -struct DigestPair { - prior: Option, - current: Option, + Ok(()) } -/// Enforce operation, outcome, and digest consistency between one decision -/// (or receipt) and the request partition its chunk belongs to. -fn check_decision( - partition: Partition, - expected: &DigestPair, - decision: &ChunkProjectionDecisionV1, - reembed_reused: bool, +fn check_receipt( + change: &ChangedCodeChunkV1, + receipt: &CodeChunkProjectionReceiptV1, ) -> Result<(), ProjectionReceiptErrorV1> { - let inconsistent = - || ProjectionReceiptErrorV1::InconsistentOperation(decision.chunk_id.clone()); - if decision.prior_chunk_digest != expected.prior - || decision.current_chunk_digest != expected.current + if receipt.prior_chunk_digest != change.prior_digest + || receipt.current_chunk_digest != change.current_digest + || receipt.operation != applied_operation(change) + || receipt.outcome == ProjectionOutcomeV1::Reused { - return Err(inconsistent()); - } - if partition != Partition::Reused && decision.outcome == ProjectionOutcomeV1::Reused { - return Err(inconsistent()); - } - match partition { - Partition::AddedOrChanged => { - let expected_operation = if expected.prior.is_none() { - ProjectionOperationV1::Added - } else { - ProjectionOperationV1::Updated - }; - if decision.operation != expected_operation { - return Err(inconsistent()); - } - } - Partition::Deleted => { - if decision.operation != ProjectionOperationV1::Deleted { - return Err(inconsistent()); - } - } - Partition::Reused => { - let valid = if reembed_reused { - decision.operation == ProjectionOperationV1::Updated - && decision.outcome == ProjectionOutcomeV1::Applied - } else { - decision.operation == ProjectionOperationV1::Reused - && decision.outcome == ProjectionOutcomeV1::Reused - }; - if !valid { - return Err(inconsistent()); - } - } + return Err(ProjectionReceiptErrorV1::InconsistentOperation( + receipt.chunk_id.clone(), + )); } - // Output digests: an applied upsert emits output; deletions, reuses, - // skips, and failures emit none. - let emits_output = decision.operation != ProjectionOperationV1::Deleted - && decision.outcome == ProjectionOutcomeV1::Applied; - if decision.output_digest.is_some() != emits_output { - return Err(inconsistent()); + // Applied upserts require an output, but that output may legitimately + // differ from the source digest. Deletions and failed/skipped work have none. + let emits_output = receipt.operation != ProjectionOperationV1::Deleted + && receipt.outcome == ProjectionOutcomeV1::Applied; + if receipt.output_digest.is_some() != emits_output { + return Err(ProjectionReceiptErrorV1::InconsistentOperation( + receipt.chunk_id.clone(), + )); } Ok(()) } diff --git a/crates/tracedecay-code-index/tests/code_index_suite/cross_file_import_calls.rs b/crates/tracedecay-code-index/tests/code_index_suite/cross_file_import_calls.rs index 9a6a6d5982..81d2e0ca5b 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/cross_file_import_calls.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/cross_file_import_calls.rs @@ -689,6 +689,7 @@ fn disclosed_gaps(generation: &CodeIndexPublishedGenerationV1, file: &str) -> BT .collect::>(); generation .unresolved_import_calls() + .expect("sealed import-call evidence resolves") .into_iter() .filter(|gap| in_file.contains(&gap.from_occurrence)) .map(|gap| gap.reference_name) 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 576af14f10..4b4f852f64 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 @@ -6,6 +6,9 @@ use std::{ sync::{Arc, Mutex}, time::Duration, }; +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, detected_process_resident_memory_limit_v1, +}; use sha2::{Digest, Sha256}; use tracedecay_code_extraction::incremental::ParseLimits; @@ -20,15 +23,16 @@ use tracedecay_code_index::{ production::{ CodeIndexAtomicPublicationPort, CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, CodeIndexExecutionControlV1, CodeIndexGenerationScopeV1, CodeIndexInterruptionV1, - CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, - CodeIndexPublicationStoreErrorV1, CodeIndexPublishedBuildV1, + CodeIndexPreparationPort, CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, + CodeIndexProductionOwnerV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedBuildV1, CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, - CodeIndexSealedGenerationV1, CodeIndexSealedPublicationV1, MemorySealedPublicationStoreV1, - SEALED_GENERATION_FORMAT_REVISION_V1, SealedGenerationSegmentPublicationV1, - SealedGenerationSegmentReadV1, SharedDecodedContentPoolV1, - SharedPhysicalCodeArtifactPoolV1, VerifiedSealedLexicalPageReadV1, - VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, - advance_import_dictionary_digest, initial_import_dictionary_digest, + CodeIndexSealedGenerationV1, CodeIndexSealedPublicationV1, MemoryCodeIndexPreparationV1, + MemorySealedPublicationStoreV1, SEALED_GENERATION_FORMAT_REVISION_V1, + SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, SealedSegmentReaderV1, + SharedDecodedContentPoolV1, SharedPhysicalCodeArtifactPoolV1, + VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, + VerifiedSealedLexicalPageV1, advance_import_dictionary_digest, + initial_import_dictionary_digest, }, provider::GenerationTestAttributionJoinReadPort, retained_parse::{RetainedParsePoolLimits, SharedRetainedParsePool}, @@ -68,20 +72,13 @@ impl SharedPublicationStore { &self, published: &CodeIndexPublishedBuildV1, ) -> Arc { - published.decoded().map_or_else( - || { - Arc::new( - self.sealed - .decode(published.manifest_bytes()) - .expect("published generation decodes"), - ) - }, - Arc::clone, - ) + cold_generation(published) } } impl CodeIndexAtomicPublicationPort for SharedPublicationStore { + type Preparation = MemoryCodeIndexPreparationV1; + fn load_active( &self, scope: &CodeIndexGenerationScopeV1, @@ -89,14 +86,20 @@ impl CodeIndexAtomicPublicationPort for SharedPublicationStore { self.sealed.load_active(scope) } - fn publish_atomically( + fn commit( + &mut self, + preparation: &mut Self::Preparation, + publication: &CodeIndexSealedPublicationV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + self.sealed.commit(preparation, publication) + } + + fn prepare( &mut self, scope: &CodeIndexGenerationScopeV1, expected_active_generation: Option<&CodeGenerationId>, - generation: &CodeIndexSealedPublicationV1, - ) -> Result, CodeIndexPublicationStoreErrorV1> { - self.sealed - .publish_atomically(scope, expected_active_generation, generation) + ) -> Result { + self.sealed.prepare(scope, expected_active_generation) } } @@ -109,7 +112,52 @@ struct PartialKeyPublicationStore { pointer: Arc>>, } +impl PartialKeyPublicationStore { + fn incumbent( + &self, + pointer: Option<&CodeIndexGenerationScopeV1>, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + let Some(scope) = pointer else { + return Ok(None); + }; + let active = self.sealed.load_active(scope)?.ok_or_else(|| { + CodeIndexPublicationStoreErrorV1::Unavailable( + "fixture slot names an absent canonical generation".to_owned(), + ) + })?; + let metadata = active + .metadata() + .map_err(|error| CodeIndexPublicationStoreErrorV1::Unavailable(error.to_string()))?; + Ok(Some(metadata.manifest().generation_id.clone())) + } +} + +struct PartialKeyPreparation { + inner: MemoryCodeIndexPreparationV1, + scope: CodeIndexGenerationScopeV1, + expected: Option, +} + +impl CodeIndexPreparationPort for PartialKeyPreparation { + fn write_segment( + &mut self, + publication: SealedGenerationSegmentPublicationV1<'_>, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + self.inner.write_segment(publication) + } + + fn segment_reader(&self) -> Arc { + self.inner.segment_reader() + } + + fn abort(self) -> Result<(), CodeIndexPublicationStoreErrorV1> { + self.inner.abort() + } +} + impl CodeIndexAtomicPublicationPort for PartialKeyPublicationStore { + type Preparation = PartialKeyPreparation; + fn load_active( &self, _scope: &CodeIndexGenerationScopeV1, @@ -121,20 +169,48 @@ impl CodeIndexAtomicPublicationPort for PartialKeyPublicationStore { } } - fn publish_atomically( + fn commit( + &mut self, + preparation: &mut Self::Preparation, + publication: &CodeIndexSealedPublicationV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + let mut pointer = self.pointer.lock().expect("publication lock"); + if self.incumbent(pointer.as_ref())?.as_ref() != preparation.expected.as_ref() { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + let manifest = self.sealed.commit(&mut preparation.inner, publication)?; + *pointer = Some(preparation.scope.clone()); + Ok(manifest) + } + + fn prepare( &mut self, scope: &CodeIndexGenerationScopeV1, expected_active_generation: Option<&CodeGenerationId>, - generation: &CodeIndexSealedPublicationV1, - ) -> Result, CodeIndexPublicationStoreErrorV1> { - let pointer = self - .pointer - .lock() - .expect("publication lock") - .get_or_insert_with(|| scope.clone()) - .clone(); - self.sealed - .publish_atomically(&pointer, expected_active_generation, generation) + ) -> Result { + let pointer = self.pointer.lock().expect("publication lock"); + if self.incumbent(pointer.as_ref())?.as_ref() != expected_active_generation { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + // The faulty selector has one slot, but its storage still binds every + // preparation to the exact requested full scope. A label move fences + // the old slot above and the destination's canonical incumbent here. + let destination = self + .sealed + .load_active(scope)? + .map(|active| { + active + .metadata() + .map(|metadata| metadata.manifest().generation_id.clone()) + }) + .transpose() + .map_err(|error| CodeIndexPublicationStoreErrorV1::Unavailable(error.to_string()))?; + let inner = self.sealed.prepare(scope, destination.as_ref())?; + Ok(PartialKeyPreparation { + inner, + scope: scope.clone(), + expected: expected_active_generation.cloned(), + }) } } @@ -276,6 +352,9 @@ fn request_at_path( }; CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot, captured_files: vec![CodeIndexCapturedFileV1 { file_occurrence_id: file.file_occurrence_id, @@ -704,97 +783,115 @@ fn unchanged_increment_does_not_reextract_carried_files() { assert_eq!(clone_after_restored.clone_payloads_reused, 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. +/// A completed build retains sealed segments, while the weak extraction index +/// releases its full artifacts after staging. Explicitly decoding the seal +/// does not transfer those new allocations into that extraction index. #[test] -fn physical_artifact_pool_does_not_retain_a_dropped_generation() { +fn published_build_and_decode_do_not_pin_physical_extraction_artifacts() { let pool = SharedPhysicalCodeArtifactPoolV1::default(); - { - let store = SharedPublicationStore::default(); - let mut owner = CodeIndexProductionOwnerV1::new(config(), store.clone()) - .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"); - let generation = store.generation(&generation); - - assert_eq!(pool.stats().resident, 1); - drop(generation); - } - + let store = SharedPublicationStore::default(); + let mut owner = CodeIndexProductionOwnerV1::new(config(), store.clone()) + .expect("production owner") + .with_physical_artifact_pool(pool.clone()); + let published = owner + .build_and_publish(request("file.physical.1", 1_100_000), &ActiveControl) + .expect("generation publishes"); + assert_eq!(pool.stats().inserted, 1); assert_eq!( pool.stats().resident, 0, - "the reuse index must not pin a second copy of a dropped generation" + "staging releases the extracted corpus" ); + let generation = store.generation(&published); + assert!(!generation.chunks().chunks().is_empty()); + assert_eq!( + pool.stats().resident, + 0, + "a decode owns separate serving content" + ); + drop(generation); + drop(published); + drop(owner); + pool.release_dead_entries(); + assert_eq!(pool.stats().resident, 0); } -/// Physical reuse is keyed by logical path and content digest, not -/// worktree-local `file_occurrence_id`. Linked worktrees therefore share -/// parse/chunk artifacts for identical bytes; rematerialize rebinds the -/// requesting occurrence. Same-occurrence reuse must still seal every -/// durable byte as a cold rebuild. Distinct bytes must miss. +/// A restarted owner reuses the canonical sealed parent's file segments +/// without retaining or extracting the decoded corpus. An independently +/// cold-seeded history produces the same complete successor bytes. #[test] -fn physical_artifact_reuse_preserves_byte_exact_sealed_generation() { - let pool = SharedPhysicalCodeArtifactPoolV1::default(); +fn sealed_parent_reuse_preserves_byte_exact_generation_and_foreign_identity() { + let store = SharedPublicationStore::default(); let mut source_owner = - CodeIndexProductionOwnerV1::new(config(), SharedPublicationStore::default()) - .expect("source owner") - .with_physical_artifact_pool(pool.clone()); + CodeIndexProductionOwnerV1::new(config(), store.clone()).expect("source owner"); let source = source_owner .build_and_publish(request("file.physical.target", 1_100_000), &ActiveControl) .expect("source generation publishes"); - let source = cold_generation(&source); - assert_eq!(pool.stats().reused, 0); - assert_eq!(pool.stats().inserted, 1); + let source_manifest: serde_json::Value = + serde_json::from_slice(source.manifest_bytes()).expect("source partitioned manifest"); + drop(source_owner); let mut reused_owner = - CodeIndexProductionOwnerV1::new(config(), SharedPublicationStore::default()) - .expect("reuse owner") - .with_physical_artifact_pool(pool.clone()); + CodeIndexProductionOwnerV1::new(config(), store).expect("restarted reuse owner"); let reused = reused_owner .build_and_publish(request("file.physical.target", 1_200_000), &ActiveControl) - .expect("physically reused generation publishes"); - let reused = cold_generation(&reused); + .expect("sealed parent successor publishes"); + assert_eq!(reused.cold_reason(), None); + assert_eq!(reused_owner.retained_parse_stats().full_extractions, 0); + assert_eq!(reused_owner.retained_parse_stats().initial_parses, 0); + assert_eq!(reused.lane_digest(), source.lane_digest()); + let reused_manifest: serde_json::Value = + serde_json::from_slice(reused.manifest_bytes()).expect("successor partitioned manifest"); assert_eq!( - pool.stats().reused, - 1, - "identical path and content must reuse the source physical artifact" + reused_manifest["generation"]["file_segments"], + source_manifest["generation"]["file_segments"], + "the restarted owner must carry the exact parent file segment descriptors" + ); + assert_eq!( + reused.projection().request().changes.reused_count, + source.projection().request().changes.added_or_changed.len() as u64, + "every source chunk is reused through the sealed parent" + ); + let reused_bytes = Arc::clone(reused.manifest_bytes()); + let reused = cold_generation(&reused); + assert!( + reused.is_validated(), + "the explicit decode verifies the restored successor" + ); + assert!( + !reused + .admitted_chunks() + .expect("restored exact authority") + .is_empty() ); let mut cold_owner = CodeIndexProductionOwnerV1::new(config(), SharedPublicationStore::default()) - .expect("cold owner"); + .expect("independent comparison owner"); + cold_owner + .build_and_publish(request("file.physical.target", 1_100_000), &ActiveControl) + .expect("independent cold seed publishes"); let cold = cold_owner .build_and_publish(request("file.physical.target", 1_200_000), &ActiveControl) - .expect("cold comparison generation publishes"); + .expect("independent comparison successor publishes"); + assert_eq!( + reused_bytes.as_ref(), + cold.manifest_bytes().as_ref(), + "published manifest bytes mismatch" + ); let cold = cold_generation(&cold); let foreign_occurrence = id::("file.physical.foreign"); let mut foreign_owner = CodeIndexProductionOwnerV1::new(config(), SharedPublicationStore::default()) - .expect("foreign occurrence owner") - .with_physical_artifact_pool(pool.clone()); + .expect("foreign occurrence owner"); let foreign = foreign_owner .build_and_publish(request("file.physical.foreign", 1_200_000), &ActiveControl) - .expect("foreign occurrence rematerializes from the shared artifact"); + .expect("foreign occurrence publishes with its own identity"); let foreign = cold_generation(&foreign); - assert_eq!( - pool.stats().reused, - 2, - "same path and content must share artifacts across worktree-local occurrence ids" - ); - assert_eq!( - pool.stats().inserted, - 1, - "occurrence-id-free reuse must not insert a second artifact for the same bytes" - ); assert!( !foreign.chunks().chunks().is_empty(), - "foreign rematerialize must produce chunks" + "a foreign occurrence must produce chunks" ); assert!( foreign @@ -802,7 +899,7 @@ fn physical_artifact_reuse_preserves_byte_exact_sealed_generation() { .chunks() .iter() .all(|chunk| chunk.anchor.file_occurrence_id == foreign_occurrence), - "rematerialize must rebind shared chunks onto the requesting occurrence" + "published chunks must bind the requesting foreign occurrence" ); assert_ne!( PartitionedSealV1::of(&foreign).manifest, @@ -817,20 +914,24 @@ fn physical_artifact_reuse_preserves_byte_exact_sealed_generation() { changed.captured_files[0].sanitized_bytes = Arc::from(changed_source.as_slice()); let mut changed_owner = CodeIndexProductionOwnerV1::new(config(), SharedPublicationStore::default()) - .expect("changed-content owner") - .with_physical_artifact_pool(pool.clone()); - changed_owner + .expect("changed-content owner"); + let changed = changed_owner .build_and_publish(changed, &ActiveControl) - .expect("different content publishes without borrowing the prior artifact"); - assert_eq!( - pool.stats().reused, - 2, - "different content digest must not reuse the prior physical artifact" - ); - assert_eq!( - pool.stats().inserted, - 2, - "a content miss must insert its own physical artifact" + .expect("different content publishes without borrowing prior identities"); + let changed = cold_generation(&changed); + let prior_ids = reused + .chunks() + .chunks() + .iter() + .map(|chunk| &chunk.id) + .collect::>(); + assert!(!changed.chunks().chunks().is_empty()); + assert!( + changed + .chunks() + .chunks() + .iter() + .all(|chunk| !prior_ids.contains(&chunk.id)) ); assert_eq!(reused.manifest(), cold.manifest(), "manifest mismatch"); @@ -859,7 +960,7 @@ fn physical_artifact_reuse_preserves_byte_exact_sealed_generation() { assert_eq!( PartitionedSealV1::of(&reused), PartitionedSealV1::of(&cold), - "sharing the physical allocation must preserve every durable byte and digest" + "sealed parent reuse must preserve every durable byte and digest" ); drop(source); } @@ -901,6 +1002,9 @@ fn resumed_parse_quanta_publish_the_same_complete_generation() { disposition: SnapshotFileDispositionV1::Present, }; let request = CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot: SanitizedCodeSnapshotV1 { repository: id::("repository.production"), worktree: None, @@ -1249,8 +1353,8 @@ fn production_owner_publishes_complete_generation_and_restores_it_after_restart( .is_empty() ); assert_eq!( - first.projection().receipt().source_generation, - first.manifest().generation_id + first.projection().source_generation(), + &first.manifest().generation_id ); let first_commitments = first .manifest() @@ -2647,6 +2751,18 @@ fn config_dispatch_refuses_a_generation_sealed_for_another_full_scope() { hawk.manifest() ); + let mut competing = store.clone(); + assert!( + matches!( + competing.prepare(&moved_label_scope, None), + Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap) + ), + "a label move must name the occupied physical slot's incumbent" + ); + let mut stale_preparation = competing + .prepare(&moved_label_scope, Some(&hawk.manifest().generation_id)) + .expect("competing label move prepares against the same incumbent"); + let moved = owner .build_and_publish( request_in_scope( @@ -2659,12 +2775,36 @@ fn config_dispatch_refuses_a_generation_sealed_for_another_full_scope() { &ActiveControl, ) .expect("label-move publish must CAS against the prior-label incumbent"); + assert!( + matches!( + competing.commit(&mut stale_preparation, moved.publication()), + Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap) + ), + "the losing label move must not commit against its superseded incumbent" + ); + stale_preparation.abort().expect("abort losing label move"); let moved = cold_generation(&moved); assert_ne!( moved.manifest().generation_id, hawk.manifest().generation_id, "the rebuilt generation is a new publication, not the reused hawk seal" ); + let selected = store + .load_active(&moved_label_scope) + .expect("physical slot reads") + .expect("moved generation remains selected") + .metadata() + .expect("selected manifest verifies"); + assert_eq!(selected.manifest(), moved.manifest()); + assert!( + matches!( + owner.active_generation(&pr_worktree_scope), + Err(CodeIndexProductionErrorV1::Publication( + CodeIndexPublicationStoreErrorV1::CorruptionResetRequired(_) + )) + ), + "the label move must preserve foreign-checkout corruption detection" + ); } /// The code shard/slot key is the sealed branch label inside the full scope, @@ -2854,6 +2994,9 @@ fn partitioned_request( ))); } CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot: SanitizedCodeSnapshotV1 { repository: id::("repository.production"), worktree: None, 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 2e34eef938..a23f502b50 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,7 @@ use std::{collections::BTreeSet, sync::Arc}; +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, detected_process_resident_memory_limit_v1, +}; use tracedecay_code_index::{ chunks::content_digest, @@ -69,6 +72,9 @@ fn multi_file_request(file_count: usize, sealed_at: i64) -> CodeIndexBuildReques ); CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot: SanitizedCodeSnapshotV1 { repository: id::("repository.production"), worktree: None, diff --git a/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs b/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs index e8a39b265c..1e93086561 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/relation_coverage.rs @@ -290,6 +290,7 @@ fn typescript_calls_on_computed_receivers_make_callers_partial() { let mut gaps = graph .generation .unresolved_import_calls() + .expect("canonical import calls resolve") .into_iter() .map(|gap| { let span = gap.evidence_span.start_byte as usize..gap.evidence_span.end_byte as usize; diff --git a/crates/tracedecay-code-index/tests/code_index_suite/support.rs b/crates/tracedecay-code-index/tests/code_index_suite/support.rs index d3fe6f15c5..5c3bb4626d 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/support.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/support.rs @@ -87,14 +87,18 @@ pub fn validated_rust_file(source: &[u8]) -> ReceiptBoundCodeFileV1 { .expect("receipt-bound rust source") } -/// The generation a first build holds: with no sealed parent it ran cold. +/// Decode the published generation explicitly through its canonical sealed reader. pub fn cold_generation( published: &CodeIndexPublishedBuildV1, ) -> Arc { - Arc::clone( - published - .decoded() - .expect("a build without a parent runs cold"), + let reader = published.sealed_generation().segment_reader(); + Arc::new( + CodeIndexPublishedGenerationV1::decode_partitioned_sealed( + published.manifest_bytes(), + &SharedDecodedContentPoolV1::default(), + |request, buffer| reader(request, buffer), + ) + .expect("published generation decodes"), ) } diff --git a/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs b/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs index 511c01503f..6850e30622 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/typescript_module_resolution.rs @@ -216,7 +216,9 @@ fn same_module_export_clauses_bind_the_forwarded_binding() { // `relayedMissing` forwards an import of a module that is not indexed: // no edge, and the call site stays a disclosed gap. - let gaps = generation.unresolved_import_calls(); + let gaps = generation + .unresolved_import_calls() + .expect("canonical import calls resolve"); assert!( gaps.iter() .any(|gap| gap.reference_name == "relayedMissing"), @@ -391,11 +393,16 @@ fn sealed_replay_recomputes_identical_typescript_edges() { let restored = PartitionedSealV1::of(&generation).restored(); assert_eq!(restored.edges(), generation.edges()); assert_eq!( - restored.unresolved_import_calls(), - generation.unresolved_import_calls() + restored + .unresolved_import_calls() + .expect("canonical import calls resolve"), + generation + .unresolved_import_calls() + .expect("canonical import calls resolve") ); let mut unresolved = generation .unresolved_import_calls() + .expect("canonical import calls resolve") .into_iter() .map(|reference| reference.reference_name) .collect::>(); @@ -480,6 +487,7 @@ fn undeclared_missing_relative_stays_unresolved() { crate::cross_file_import_calls::publish_fixture_tree(root.path(), "undeclared-relative"); let unresolved = generation .unresolved_import_calls() + .expect("canonical import calls resolve") .into_iter() .map(|reference| reference.reference_name) .collect::>(); @@ -689,6 +697,7 @@ fn nested_package_missing_file_is_not_claimed_by_root_mapping() { crate::cross_file_import_calls::publish_fixture_tree(root.path(), "nested-package"); let unresolved = generation .unresolved_import_calls() + .expect("canonical import calls resolve") .into_iter() .map(|reference| reference.reference_name) .collect::>(); diff --git a/crates/tracedecay-code-index/tests/resident_accounting.rs b/crates/tracedecay-code-index/tests/resident_accounting.rs index 9036247b8c..18a6afcf49 100644 --- a/crates/tracedecay-code-index/tests/resident_accounting.rs +++ b/crates/tracedecay-code-index/tests/resident_accounting.rs @@ -8,6 +8,9 @@ use std::alloc::{GlobalAlloc, Layout, System}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, detected_process_resident_memory_limit_v1, +}; use sha2::{Digest, Sha256}; use tracedecay_code_index::chunks::content_digest; @@ -15,21 +18,19 @@ use tracedecay_code_index::graph_projection::{ CODE_GRAPH_PROJECTOR_REVISION, build_sealed_code_graph_rows, code_graph_projection_identity, }; use tracedecay_code_index::production::{ - CodeIndexAtomicPublicationPort, CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, - CodeIndexExecutionControlV1, CodeIndexGenerationScopeV1, CodeIndexProductionConfigV1, - CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, CodeIndexPublicationStoreErrorV1, + CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, CodeIndexExecutionControlV1, + CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, CodeIndexPublishedBuildV1, CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, - CodeIndexSealedGenerationV1, CodeIndexSealedPublicationV1, SealedGenerationFileWindowsV1, + MemorySealedPublicationStoreV1, SealedGenerationFileWindowsV1, SealedGenerationSegmentPublicationV1, SealedGenerationSegmentReadV1, SharedDecodedContentPoolV1, SharedPhysicalCodeArtifactPoolV1, }; use tracedecay_domain::test_fixtures::id; use tracedecay_domain::{ - ChunkerRevision, CodeGenerationId, FileOccurrenceId, LanguageId, ManifestDigest, - PolicyRevisionId, PrivacyDomainId, ProjectId, ProjectionKeyV1, ProjectionKindV1, - RepositoryDirtyStateV1, RepositoryId, SanitizationReceiptId, SanitizedCodeFileV1, - SanitizedCodeSnapshotV1, SanitizerRevision, SensitivityLevelV1, SnapshotFileDispositionV1, - UtcMicros, + ChunkerRevision, FileOccurrenceId, LanguageId, ManifestDigest, PolicyRevisionId, + PrivacyDomainId, ProjectId, ProjectionKeyV1, ProjectionKindV1, RepositoryDirtyStateV1, + RepositoryId, SanitizationReceiptId, SanitizedCodeFileV1, SanitizedCodeSnapshotV1, + SanitizerRevision, SensitivityLevelV1, SnapshotFileDispositionV1, UtcMicros, }; use tracedecay_graph_db::{GraphGenerationRowSpill, GraphNamespace, GraphProjectorRevision}; @@ -68,34 +69,14 @@ static ALLOCATOR: CountingAllocator = CountingAllocator; /// The counters are process-wide, so measurements run one at a time. static MEASUREMENT: Mutex<()> = Mutex::new(()); -/// Seals each generation and keeps none of it: these measurements count what -/// a build and its decode hold, not what a store holds. -#[derive(Default)] -struct Publication; - -impl CodeIndexAtomicPublicationPort for Publication { - fn load_active( - &self, - _scope: &CodeIndexGenerationScopeV1, - ) -> Result, CodeIndexPublicationStoreErrorV1> { - Ok(None) - } - - fn publish_atomically( - &mut self, - _scope: &CodeIndexGenerationScopeV1, - _expected_active_generation: Option<&CodeGenerationId>, - generation: &CodeIndexSealedPublicationV1, - ) -> Result, CodeIndexPublicationStoreErrorV1> { - generation - .encode(None, |_| Ok(())) - .map(Arc::from) - .map_err(|error| CodeIndexPublicationStoreErrorV1::Unavailable(error.to_string())) - } -} - -fn decoded(built: &CodeIndexPublishedBuildV1) -> &CodeIndexPublishedGenerationV1 { - built.decoded().expect("a build without a parent runs cold") +fn decoded(built: &CodeIndexPublishedBuildV1) -> CodeIndexPublishedGenerationV1 { + let reader = built.sealed_generation().segment_reader(); + CodeIndexPublishedGenerationV1::decode_partitioned_sealed( + built.manifest_bytes(), + &SharedDecodedContentPoolV1::default(), + |request, buffer| reader(request, buffer), + ) + .expect("published generation decodes") } struct Active; @@ -153,6 +134,9 @@ fn request(files: usize) -> CodeIndexBuildRequestV1 { ))); } CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot: SanitizedCodeSnapshotV1 { repository: id::("repository.accounting"), worktree: None, @@ -277,18 +261,19 @@ fn decode(manifest: &[u8], segments: &BTreeMap>) -> CodeIndexPub fn a_sealed_graph_build_holds_windows_not_the_decoded_generation() { const PEAK_BUDGET_BYTES: usize = 19_000_000; let _measurement = MEASUREMENT.lock().unwrap_or_else(PoisonError::into_inner); - let built = CodeIndexProductionOwnerV1::new(config(), Publication) - .expect("owner") - .build_and_publish(request(300), &Active) - .expect("build"); - let (manifest, segments) = seal(decoded(&built)); + let built = + CodeIndexProductionOwnerV1::new(config(), MemorySealedPublicationStoreV1::default()) + .expect("owner") + .build_and_publish(request(300), &Active) + .expect("build"); + let generation = decoded(&built); + let (manifest, segments) = seal(&generation); // One worker reads four files per window, so the 300-file fixture spans // 75 windows the way a production corpus spans its windows. tracedecay_code_index::parallelism::force_indexing_workers_for_test(1); - let bound = decoded(&built) - .graph_build_bound() - .expect("graph build bound"); - let retained = usize::try_from(decoded(&built).retained_bytes()).expect("retained"); + let bound = generation.graph_build_bound().expect("graph build bound"); + let retained = usize::try_from(generation.retained_bytes()).expect("retained"); + drop(generation); drop(built); let scratch = tempfile::tempdir().expect("scratch"); let projection = @@ -333,28 +318,31 @@ fn a_sealed_graph_build_holds_windows_not_the_decoded_generation() { } #[test] -fn a_full_build_leaves_live_only_what_its_generation_charges() { +fn a_published_generation_decode_leaves_live_only_what_its_generation_charges() { let _measurement = MEASUREMENT.lock().unwrap_or_else(PoisonError::into_inner); // Grammars and extractor registries initialize once per process. drop( - CodeIndexProductionOwnerV1::new(config(), Publication) + CodeIndexProductionOwnerV1::new(config(), MemorySealedPublicationStoreV1::default()) .expect("owner") .build_and_publish(request(300), &Active) .expect("warm build"), ); - let before = LIVE.load(Ordering::Relaxed); - let mut owner = CodeIndexProductionOwnerV1::new(config(), Publication).expect("owner"); + let mut owner = + CodeIndexProductionOwnerV1::new(config(), MemorySealedPublicationStoreV1::default()) + .expect("owner"); let built = owner .build_and_publish(request(300), &Active) .expect("build"); + // Durable segment ownership is established before measuring the decode. + let before = LIVE.load(Ordering::Relaxed); + let generation = decoded(&built); let live = LIVE.load(Ordering::Relaxed) - before; - let retained = usize::try_from(decoded(&built).retained_bytes()).expect("retained"); - eprintln!("ACCOUNTING full build live {live} retained {retained}"); + let retained = usize::try_from(generation.retained_bytes()).expect("retained"); assert_eq!(owner.retained_parse_stats().initial_parses, 300); assert!( live * 10 <= retained * 11, - "a full build left {live} bytes live but its generation charges {retained}" + "a published generation decode left {live} bytes live but its generation charges {retained}" ); } @@ -365,26 +353,29 @@ fn a_full_build_leaves_live_only_what_its_generation_charges() { fn a_dropped_generation_leaves_nothing_once_the_pool_drops_its_dead_entries() { let _measurement = MEASUREMENT.lock().unwrap_or_else(PoisonError::into_inner); drop( - CodeIndexProductionOwnerV1::new(config(), Publication) + CodeIndexProductionOwnerV1::new(config(), MemorySealedPublicationStoreV1::default()) .expect("owner") .build_and_publish(request(300), &Active) .expect("warm build"), ); let pool = SharedPhysicalCodeArtifactPoolV1::default(); - let mut owner = CodeIndexProductionOwnerV1::new(config(), Publication) - .expect("owner") - .with_physical_artifact_pool(pool.clone()); - let before = LIVE.load(Ordering::Relaxed); - drop( - owner - .build_and_publish(request(300), &Active) - .expect("build"), - ); + { + let mut owner = + CodeIndexProductionOwnerV1::new(config(), MemorySealedPublicationStoreV1::default()) + .expect("owner") + .with_physical_artifact_pool(pool.clone()); + drop( + owner + .build_and_publish(request(300), &Active) + .expect("build"), + ); + // Release the sole fixture store along with the owner; only the + // process-lifetime pool's weak entries remain in this measurement. + } let pinned = LIVE.load(Ordering::Relaxed).saturating_sub(before); pool.release_dead_entries(); let left = LIVE.load(Ordering::Relaxed).saturating_sub(before); - eprintln!("ACCOUNTING dead entries pinned {pinned} left {left}"); assert_eq!(pool.stats().resident, 0); assert!( pinned >= 300 * 512, @@ -399,20 +390,19 @@ fn a_dropped_generation_leaves_nothing_once_the_pool_drops_its_dead_entries() { #[test] fn retained_bytes_account_for_what_a_decode_leaves_live() { let _measurement = MEASUREMENT.lock().unwrap_or_else(PoisonError::into_inner); - let built = CodeIndexProductionOwnerV1::new(config(), Publication) - .expect("owner") - .build_and_publish(request(300), &Active) - .expect("build"); - let (manifest, segments) = seal(decoded(&built)); + let built = + CodeIndexProductionOwnerV1::new(config(), MemorySealedPublicationStoreV1::default()) + .expect("owner") + .build_and_publish(request(300), &Active) + .expect("build"); + let (manifest, segments) = seal(&decoded(&built)); drop(built); let before = LIVE.load(Ordering::Relaxed); PEAK.store(before, Ordering::Relaxed); let decoded = decode(&manifest, &segments); let live = LIVE.load(Ordering::Relaxed) - before; - eprintln!("ACCOUNTING peak {}", PEAK.load(Ordering::Relaxed) - before); let retained = usize::try_from(decoded.retained_bytes()).expect("retained"); - eprintln!("ACCOUNTING live {live} retained {retained}"); assert!( retained * 10 >= live * 9 && retained * 10 <= live * 11, "a decode left {live} bytes live but retained_bytes() reports {retained}" diff --git a/crates/tracedecay-domain/src/code_intelligence/search.rs b/crates/tracedecay-domain/src/code_intelligence/search.rs index 905d98143a..d9f2e81214 100644 --- a/crates/tracedecay-domain/src/code_intelligence/search.rs +++ b/crates/tracedecay-domain/src/code_intelligence/search.rs @@ -808,28 +808,33 @@ pub struct ChangedCodeChunkV1 { /// generation emits empty `added_or_changed` and `deleted` sets with a /// non-zero `reused_count`. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] +#[serde( + deny_unknown_fields, + rename(serialize = "$tracedecay::canonical::ordered_object") +)] pub struct ChangedCodeChunkSetV1 { - pub from_generation: Option, - pub to_generation: CodeGenerationId, - pub manifest_digest: ManifestDigest, + // Keep fields lexicographic so canonical hashing streams the row sequences. pub added_or_changed: Vec, pub deleted: Vec, + pub from_generation: Option, + pub manifest_digest: ManifestDigest, /// Request-authenticated unchanged chunks that required no projector work. pub reused_count: u64, /// Canonical digest over the ordered reused `(chunk_id, content_digest)` pairs. pub reused_digest: ManifestDigest, + pub to_generation: CodeGenerationId, } #[derive(Serialize)] +#[serde(rename = "$tracedecay::canonical::ordered_object")] struct ChangedCodeChunkSetDigestInput<'a> { - domain: &'static str, - from_generation: &'a Option, - to_generation: &'a CodeGenerationId, added_or_changed: &'a [ChangedCodeChunkV1], deleted: &'a [ChangedCodeChunkV1], + domain: &'static str, + from_generation: &'a Option, reused_count: u64, reused_digest: &'a ManifestDigest, + to_generation: &'a CodeGenerationId, } #[derive(Serialize)] @@ -915,11 +920,12 @@ pub struct CodeGenerationSourceCommitmentsV1 { } #[derive(Serialize)] +#[serde(rename = "$tracedecay::canonical::ordered_object")] struct CodeSourceParentDeltaDigestInput<'a> { - domain: &'static str, - parent_full_replay_digest: Option<&'a ManifestDigest>, added_or_changed: &'a [ChangedCodeChunkV1], deleted: &'a [ChangedCodeChunkV1], + domain: &'static str, + parent_full_replay_digest: Option<&'a ManifestDigest>, reused_count: u64, reused_digest: &'a ManifestDigest, } @@ -1423,14 +1429,34 @@ pub struct ProjectionBatchReceiptV1 { pub fn projection_batch_publication_digest( batch: &ProjectionBatchReceiptV1, ) -> Result { - canonical_sha256(&( - PROJECTION_PUBLICATION_SEPARATOR, + projection_publication_digest_for_receipts( &batch.target_projection_key, &batch.request_digest, &batch.source_generation, &batch.source_manifest_digest, &batch.receipts, batch.reused_count, + ) +} + +/// Seal the published receipt identity over either stored rows or a replayed +/// sequence. Both representations serialize the same canonical tuple. +pub fn projection_publication_digest_for_receipts( + target_projection_key: &ProjectionKeyV1, + request_digest: &ManifestDigest, + source_generation: &CodeGenerationId, + source_manifest_digest: &ManifestDigest, + receipts: &impl Serialize, + reused_count: u64, +) -> Result { + canonical_sha256(&( + PROJECTION_PUBLICATION_SEPARATOR, + target_projection_key, + request_digest, + source_generation, + source_manifest_digest, + receipts, + reused_count, )) } @@ -2076,6 +2102,41 @@ mod tests { assert!(mixed_generation.validate().is_err()); } + #[test] + fn streamed_change_and_parent_digests_match_canonical_json_objects() { + let changes = changed_set(); + let change_input = serde_json::json!({ + "domain": CHANGED_CODE_CHUNK_SET_DIGEST_DOMAIN, + "from_generation": changes.from_generation, + "to_generation": changes.to_generation, + "added_or_changed": changes.added_or_changed, + "deleted": changes.deleted, + "reused_count": changes.reused_count, + "reused_digest": changes.reused_digest, + }); + assert_eq!( + changes.compute_digest().unwrap(), + canonical_sha256(&change_input).unwrap() + ); + let parent = id::(&digest('e')); + let parent_input = serde_json::json!({ + "domain": CODE_SOURCE_PARENT_DELTA_DIGEST_DOMAIN, + "parent_full_replay_digest": parent, + "added_or_changed": changes.added_or_changed, + "deleted": changes.deleted, + "reused_count": changes.reused_count, + "reused_digest": changes.reused_digest, + }); + assert_eq!( + code_source_parent_delta_digest(Some(&parent), &changes).unwrap(), + canonical_sha256(&parent_input).unwrap() + ); + assert_eq!( + canonical_sha256(&changes).unwrap(), + canonical_sha256(&serde_json::to_value(&changes).unwrap()).unwrap() + ); + } + #[test] fn changed_chunk_digest_rejects_reordering_and_tampering() { let mut changes = changed_set(); diff --git a/crates/tracedecay-domain/src/lib.rs b/crates/tracedecay-domain/src/lib.rs index be90aade95..946657283e 100644 --- a/crates/tracedecay-domain/src/lib.rs +++ b/crates/tracedecay-domain/src/lib.rs @@ -76,7 +76,8 @@ pub use code_intelligence::{ is_configuration_key_token, is_identifier_token, is_path_shape, is_path_token, is_qualified_name_token, is_runtime_error_code_token, is_technical_token_char, is_tool_name_token, path_matches_scope, projection_batch_publication_digest, - repository_path_matches_scope, split_subtokens, technical_tokens, validate_code_logical_path, + projection_publication_digest_for_receipts, repository_path_matches_scope, split_subtokens, + technical_tokens, validate_code_logical_path, }; pub use configuration::{ ACCESS_RULES_SETTING_KEY, ANALYZER_SETTINGS_SETTING_KEY, AUTOMATION_SETTINGS_SETTING_KEY, diff --git a/crates/tracedecay-domain/src/research/canonical_serializer.rs b/crates/tracedecay-domain/src/research/canonical_serializer.rs index 82a3e2b64e..aadb6c6b6e 100644 --- a/crates/tracedecay-domain/src/research/canonical_serializer.rs +++ b/crates/tracedecay-domain/src/research/canonical_serializer.rs @@ -11,6 +11,10 @@ use super::error::DomainError; type ObjectEntries = Vec<(Cow<'static, str>, Range)>; +// Serde's struct name is not part of JSON. Opted-in structs emit their fields +// in canonical order, checked below, so large sequence fields can stream. +const ORDERED_OBJECT: &str = "$tracedecay::canonical::ordered_object"; + /// One object's entry buffer may keep this much rendered-value capacity when /// it returns to the pool. Canonical chunk/manifest objects render well below /// this; a corpus-scale object (a legacy whole-generation digest renders the @@ -334,6 +338,14 @@ impl<'sink, S: CanonicalSink> serde::Serializer for CanonicalSerializer<'sink, S name: &'static str, len: usize, ) -> CanonicalResult { + if name == ORDERED_OBJECT { + self.sink.write("{"); + return Ok(StructWriter::Ordered { + sink: self.sink, + pool: self.pool, + previous: None, + }); + } if name.starts_with(SERDE_JSON_PRIVATE_TOKEN_PREFIX) { return Ok(StructWriter::Delegated { sink: self.sink, @@ -608,6 +620,11 @@ impl serde::ser::SerializeStructVariant for ObjectWriter<'_, S /// Structs stream like maps, except for `serde_json`'s private tokens. enum StructWriter<'sink, S: CanonicalSink> { Object(ObjectWriter<'sink, S>), + Ordered { + sink: &'sink mut S, + pool: &'sink mut ObjectBufferPool, + previous: Option<&'static str>, + }, Delegated { sink: &'sink mut S, inner: ::SerializeStruct, @@ -624,6 +641,25 @@ impl serde::ser::SerializeStruct for StructWriter<'_, S> { { match self { Self::Object(object) => object.push_entry(Cow::Borrowed(key), value), + Self::Ordered { + sink, + pool, + previous, + } => { + if let Some(prior) = previous { + if *prior >= key { + return Err(serde::ser::Error::custom( + "canonical ordered object fields must be strictly increasing", + )); + } + sink.write(","); + } + write_json_string(key, *sink); + sink.write(":"); + value.serialize(CanonicalSerializer { sink: *sink, pool })?; + *previous = Some(key); + Ok(()) + } Self::Delegated { inner, .. } => { serde::ser::SerializeStruct::serialize_field(inner, key, value) } @@ -633,6 +669,10 @@ impl serde::ser::SerializeStruct for StructWriter<'_, S> { fn end(self) -> CanonicalResult { match self { Self::Object(object) => object.finish(), + Self::Ordered { sink, .. } => { + sink.write("}"); + Ok(()) + } Self::Delegated { sink, inner } => { let value = serde::ser::SerializeStruct::end(inner)?; write_canonical(&value, sink); diff --git a/crates/tracedecay-domain/src/research/canonical_tests.rs b/crates/tracedecay-domain/src/research/canonical_tests.rs index b4ce6ff4b7..8e12d7a3d1 100644 --- a/crates/tracedecay-domain/src/research/canonical_tests.rs +++ b/crates/tracedecay-domain/src/research/canonical_tests.rs @@ -544,3 +544,61 @@ fn zero_digest_matches_the_historical_placeholder_spelling() { format!("sha256:{}", "0".repeat(64)) ); } + +#[test] +fn ordered_struct_fields_stream_and_keep_canonical_identity() { + #[derive(Serialize)] + #[serde(rename = "$tracedecay::canonical::ordered_object")] + struct Ordered { + a: T, + z: u64, + } + let value = Ordered { + a: vec![json!({"z": 1, "a": "escaped\nvalue"})], + z: 2, + }; + assert_canonical_identity(&value, "ordered root with ordinary nested objects"); + + struct ObservedSink<'a>(&'a std::cell::Cell); + impl CanonicalSink for ObservedSink<'_> { + fn write(&mut self, chunk: &str) { + self.0.set(self.0.get() + chunk.len()); + } + } + struct ObservedRows<'a>(&'a std::cell::Cell); + impl Serialize for ObservedRows<'_> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeSeq; + let mut rows = serializer.serialize_seq(Some(32))?; + for row in 0..32 { + let before = self.0.get(); + rows.serialize_element(&row)?; + assert!( + self.0.get() > before, + "each row reaches the sink before the next row" + ); + } + rows.end() + } + } + let written = std::cell::Cell::new(0); + super::super::canonical_serializer::serialize_canonical( + &Ordered { + a: ObservedRows(&written), + z: 2, + }, + &mut ObservedSink(&written), + ) + .unwrap(); +} + +#[test] +fn ordered_struct_rejects_out_of_order_fields() { + #[derive(Serialize)] + #[serde(rename = "$tracedecay::canonical::ordered_object")] + struct Unordered { + z: u64, + a: u64, + } + assert!(canonical_sha256(&Unordered { z: 1, a: 2 }).is_err()); +} diff --git a/crates/tracedecay-query/benches/code_lexical_catchup.rs b/crates/tracedecay-query/benches/code_lexical_catchup.rs index d0cea7b267..434a75740a 100644 --- a/crates/tracedecay-query/benches/code_lexical_catchup.rs +++ b/crates/tracedecay-query/benches/code_lexical_catchup.rs @@ -2,19 +2,21 @@ //! transaction shapes. This target intentionally owns its fixture because //! query's production fixture helpers are test-private. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::fmt; use std::sync::Arc; use std::time::Instant; +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, detected_process_resident_memory_limit_v1, +}; use serde::Serialize; use sha2::{Digest, Sha256}; use tracedecay_code_index::chunks::content_digest; use tracedecay_code_index::production::{ CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, CodeIndexExecutionControlV1, - CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, - CodeIndexRepositoryParseIdentityV1, MemorySealedPublicationStoreV1, - SealedGenerationSegmentPublicationV1, VerifiedSealedLexicalPageReadV1, + CodeIndexProductionConfigV1, CodeIndexProductionOwnerV1, CodeIndexRepositoryParseIdentityV1, + MemorySealedPublicationStoreV1, SealedGenerationSegmentReadV1, VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, VerifiedSealedLexicalSourceReceiptV1, }; @@ -163,6 +165,9 @@ fn build_fixture() -> Fixture { omitted_sources: Vec::new(), }; let request = CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot, captured_files: sources .iter() @@ -206,38 +211,8 @@ fn build_fixture() -> Fixture { let generation = owner .build_and_publish(request, &control) .expect("build deterministic sealed generation"); - let mut segments = BTreeMap::new(); - let mut evidence_pack = Vec::new(); - let manifest = generation - .publication() - .encode(None, |publication| { - match publication { - SealedGenerationSegmentPublicationV1::File { digest, bytes } - | SealedGenerationSegmentPublicationV1::FileEvidence { digest, bytes } - | SealedGenerationSegmentPublicationV1::ResolutionIndex { digest, bytes } => { - segments.insert(digest.as_str().to_owned(), bytes.to_vec()); - } - SealedGenerationSegmentPublicationV1::CodeGraphPage { - page_digest, bytes, .. - } => { - segments.insert(page_digest.as_str().to_owned(), bytes.to_vec()); - } - SealedGenerationSegmentPublicationV1::GenerationEvidencePage { bytes, .. } => { - evidence_pack.extend_from_slice(bytes); - } - SealedGenerationSegmentPublicationV1::GenerationEvidenceCommit { - segment_digest, - .. - } => { - segments.insert( - segment_digest.as_str().to_owned(), - std::mem::take(&mut evidence_pack), - ); - } - } - Ok(()) - }) - .expect("encode deterministic sealed generation"); + let manifest = generation.manifest_bytes(); + let reader = generation.sealed_generation().segment_reader(); let state_digest = ManifestDigest::from_sha256_bytes(&Sha256::digest(&manifest)) .expect("sealed manifest digest"); let metadata = CodeLexicalProjectionMetadataV1 { @@ -264,13 +239,11 @@ fn build_fixture() -> Fixture { let mut source = VerifiedSealedLexicalPageSourceV1::open_partitioned_sealed( &manifest, state_digest, - move |digest, _, buffer, _control| { - let bytes = segments.get(digest.as_str()).ok_or_else(|| { - CodeIndexProductionErrorV1::Contract("benchmark segment is missing".to_owned()) - })?; - buffer.clear(); - buffer.extend_from_slice(bytes); - Ok(()) + move |digest, size_bytes, buffer, _control| { + reader( + SealedGenerationSegmentReadV1::Whole { digest, size_bytes }, + buffer, + ) }, 1, 1024 * 1024, diff --git a/crates/tracedecay-query/src/bench_support.rs b/crates/tracedecay-query/src/bench_support.rs index ca3f4a42e9..22d6d04223 100644 --- a/crates/tracedecay-query/src/bench_support.rs +++ b/crates/tracedecay-query/src/bench_support.rs @@ -18,9 +18,10 @@ use tracedecay_code_index::languages::{LanguageRegistry, StaticLanguageRegistry} use tracedecay_code_index::production::{ CodeIndexExecutionControlV1, CodeIndexProductionErrorV1, CodeIndexPublishedBuildV1, CodeIndexPublishedGenerationV1, MemorySealedPublicationStoreV1, - SealedGenerationSegmentPublicationV1, VerifiedSealedLexicalPageBatchBoundsV1, - VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageSourceV1, - VerifiedSealedLexicalPageV1, VerifiedSealedLexicalSourceReceiptV1, + SealedGenerationSegmentPublicationV1, SharedDecodedContentPoolV1, + VerifiedSealedLexicalPageBatchBoundsV1, VerifiedSealedLexicalPageBatchReadV1, + VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, + VerifiedSealedLexicalSourceReceiptV1, }; use tracedecay_domain::{LanguageId, ManifestDigest}; @@ -158,19 +159,19 @@ impl CodeIndexExecutionControlV1 for ActiveControl { } } -/// The generation `published` sealed, decoded whole from `store`. A cold -/// build already holds it. +/// Decode the published generation through its canonical sealed reader. pub(crate) fn decoded_generation( - store: &MemorySealedPublicationStoreV1, + _store: &MemorySealedPublicationStoreV1, published: &CodeIndexPublishedBuildV1, ) -> Result, String> { - if let Some(generation) = published.decoded() { - return Ok(Arc::clone(generation)); - } - store - .decode(published.manifest_bytes()) - .map(Arc::new) - .map_err(|error| format!("decode published generation: {error}")) + let reader = published.sealed_generation().segment_reader(); + CodeIndexPublishedGenerationV1::decode_partitioned_sealed( + published.manifest_bytes(), + &SharedDecodedContentPoolV1::default(), + |request, buffer| reader(request, buffer), + ) + .map(Arc::new) + .map_err(|error| format!("decode published generation: {error}")) } /// A partitioned sealed generation held in memory: the manifest, its content diff --git a/crates/tracedecay-query/src/bin/tracedecay_index_bench.rs b/crates/tracedecay-query/src/bin/tracedecay_index_bench.rs index 147649b0c9..64681d7855 100644 --- a/crates/tracedecay-query/src/bin/tracedecay_index_bench.rs +++ b/crates/tracedecay-query/src/bin/tracedecay_index_bench.rs @@ -44,6 +44,10 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, detected_process_resident_memory_limit_v1, +}; + #[path = "../bench_support.rs"] mod artifact_bench; @@ -723,6 +727,9 @@ fn build_request( omitted_sources: Vec::new(), }; CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot, captured_files, changed_files, diff --git a/crates/tracedecay-query/src/bin/tracedecay_search_bench.rs b/crates/tracedecay-query/src/bin/tracedecay_search_bench.rs index a25557d0c0..d1d6a515c8 100644 --- a/crates/tracedecay-query/src/bin/tracedecay_search_bench.rs +++ b/crates/tracedecay-query/src/bin/tracedecay_search_bench.rs @@ -26,6 +26,10 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, detected_process_resident_memory_limit_v1, +}; + #[path = "../bench_support.rs"] mod artifact_bench; @@ -710,6 +714,9 @@ fn build_request( omitted_sources: Vec::new(), }; CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot, captured_files, changed_files: files 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 f8c901c809..4cd22cd3ea 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -16,12 +16,12 @@ use tracedecay_code_index::languages::{LanguageRegistry, StaticLanguageRegistry} use tracedecay_code_index::production::{ CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, CodeIndexExecutionControlV1, CodeIndexInterruptionV1, CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, - CodeIndexProductionOwnerV1, CodeIndexRepositoryParseIdentityV1, MemorySealedPublicationStoreV1, - SealedGenerationSegmentPublicationV1, UninterruptibleCodeIndexControlV1, - VerifiedSealedLexicalCursorV1, VerifiedSealedLexicalPageBatchBoundsV1, - VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageReadV1, - VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, - VerifiedSealedLexicalSourceReceiptV1, + CodeIndexProductionOwnerV1, CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, + MemorySealedPublicationStoreV1, SealedGenerationSegmentPublicationV1, + SharedDecodedContentPoolV1, UninterruptibleCodeIndexControlV1, VerifiedSealedLexicalCursorV1, + VerifiedSealedLexicalPageBatchBoundsV1, VerifiedSealedLexicalPageBatchReadV1, + VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, + VerifiedSealedLexicalPageV1, VerifiedSealedLexicalSourceReceiptV1, }; use tracedecay_domain::{ ChunkerRevision, CodeGenerationId, CompactCandidate, ComponentRevision, @@ -58,7 +58,7 @@ use tracedecay_query::retrieval::{QUERY_EXACT_SCORE_DOMAIN_V1, QUERY_LEXICAL_SCO use tracedecay_runtime_core::resident_memory::{ ProcessResidentMemoryV1, ProcessResidentSampleV1, ResidentMemoryComponentIdV1, - ResidentMemoryKeyV1, ResidentMemoryPressureV1, + ResidentMemoryKeyV1, ResidentMemoryPressureV1, detected_process_resident_memory_limit_v1, }; /// The request authority every uncancelled fixture request runs under. @@ -406,6 +406,9 @@ pub(crate) fn real_lexical_source_fixture_from_sources( omitted_sources: Vec::new(), }; let request = CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot, captured_files: sources .iter() @@ -448,9 +451,13 @@ pub(crate) fn real_lexical_source_fixture_from_sources( let published = owner .build_and_publish(request, &ArtifactControl { cancelled: false }) .expect("production generation"); - let generation = published - .decoded() - .expect("a build without a parent runs cold"); + let reader = published.sealed_generation().segment_reader(); + let generation = CodeIndexPublishedGenerationV1::decode_partitioned_sealed( + published.manifest_bytes(), + &SharedDecodedContentPoolV1::default(), + |request, buffer| reader(request, buffer), + ) + .expect("published generation decodes"); let mut segments = BTreeMap::new(); let mut evidence_pack = Vec::new(); let manifest = generation diff --git a/crates/tracedecay-runtime-core/src/logging.rs b/crates/tracedecay-runtime-core/src/logging.rs index 8b413d0eba..a6c0425173 100644 --- a/crates/tracedecay-runtime-core/src/logging.rs +++ b/crates/tracedecay-runtime-core/src/logging.rs @@ -243,11 +243,14 @@ pub struct FormattedTracingCapture { #[cfg(feature = "test-helpers")] #[derive(Clone)] -struct CaptureBuffer(std::sync::Arc>>); +struct CaptureBuffer(std::sync::Arc>>, bool); #[cfg(feature = "test-helpers")] impl std::io::Write for CaptureBuffer { fn write(&mut self, buffer: &[u8]) -> std::io::Result { + if self.1 { + std::io::stderr().write_all(buffer)?; + } self.0 .lock() .unwrap_or_else(PoisonError::into_inner) @@ -276,16 +279,34 @@ impl FormattedTracingCapture { /// before the first captured event. #[must_use] pub fn start() -> Self { + Self::start_with_output(false) + } + + /// Also writes each line to stderr so a terminated measurement retains + /// the events emitted before its deadline. + #[must_use] + pub fn start_with_stderr() -> Self { + Self::start_with_output(true) + } + + fn start_with_output(stderr: bool) -> Self { let bytes = std::sync::Arc::new(Mutex::new(Vec::new())); let subscriber = tracing_subscriber::fmt() .with_max_level(tracing::Level::TRACE) - .without_time() .with_ansi(false) - .with_writer(CaptureBuffer(std::sync::Arc::clone(&bytes))) - .finish(); + .with_writer(CaptureBuffer(std::sync::Arc::clone(&bytes), stderr)); + let guard = if stderr { + set_tracing_capture( + subscriber + .with_timer(tracing_subscriber::fmt::time::uptime()) + .finish(), + ) + } else { + set_tracing_capture(subscriber.without_time().finish()) + }; Self { bytes, - _guard: set_tracing_capture(subscriber), + _guard: guard, } } diff --git a/crates/tracedecay-runtime-core/src/resident_memory.rs b/crates/tracedecay-runtime-core/src/resident_memory.rs index 29545eb36b..392142ad3a 100644 --- a/crates/tracedecay-runtime-core/src/resident_memory.rs +++ b/crates/tracedecay-runtime-core/src/resident_memory.rs @@ -1422,7 +1422,13 @@ impl ResidentMemorySnapshotV1 { struct ResidentMemoryStateV1 { used_bytes: u64, charges: BTreeMap, - process_shared_charges: BTreeMap, + process_shared_charges: BTreeMap< + ( + ResidentMemoryComponentIdV1, + Option, + ), + u64, + >, /// The smallest refused request per ceiling is the first that can fit. /// Shared headroom must be evaluated against each distinct ceiling. waiting_requests: BTreeMap, @@ -1462,7 +1468,12 @@ pub fn process_admission_reservations_v1() -> ProcessAdmissionReservationsV1 { .charges .iter() .map(|(key, bytes)| (key.component, *bytes)) - .chain(state.process_shared_charges.iter().map(|(c, b)| (*c, *b))); + .chain( + state + .process_shared_charges + .iter() + .map(|((c, _), b)| (*c, *b)), + ); for (component, bytes) in charges { let total = reservations.components.entry(component).or_default(); *total = total.saturating_add(bytes); @@ -1479,6 +1490,246 @@ fn register_process_admission_ledger_v1(state: &Arc ledgers.push(Arc::downgrade(state)); } +struct ResidentMemoryOperationAllocationV1 { + /// Historical requirement only; live balances belong exclusively to the + /// canonical charge map. Accessed while the authority state lock is held. + witnessed: Mutex, +} + +#[derive(Default)] +struct ResidentMemoryOperationWitnessV1 { + required_bytes: u128, + pressure_refusal: Option, +} + +#[derive(Clone, Copy)] +struct ResidentMemoryOperationPressureWitnessV1 { + required_bytes: u128, + ceiling: u64, +} + +#[derive(Clone)] +struct ResidentMemoryOperationIdentityV1(Arc); + +impl PartialEq for ResidentMemoryOperationIdentityV1 { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for ResidentMemoryOperationIdentityV1 {} + +impl PartialOrd for ResidentMemoryOperationIdentityV1 { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ResidentMemoryOperationIdentityV1 { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + Arc::as_ptr(&self.0).cmp(&Arc::as_ptr(&other.0)) + } +} + +/// Related construction regions sharing one exact allocation identity. +/// The identity is process-local and never part of a persisted owner or wire DTO. +#[derive(Clone)] +pub struct ResidentMemoryOperationV1 { + authority: Arc, + identity: ResidentMemoryOperationIdentityV1, +} + +impl fmt::Debug for ResidentMemoryOperationV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResidentMemoryOperationV1") + .field("authority", &self.authority) + .finish_non_exhaustive() + } +} + +impl ResidentMemoryOperationV1 { + pub fn authority(&self) -> &Arc { + &self.authority + } + + /// Reserve another region, witnessing the live operation plus this request + /// under the same lock that admits or refuses it. Refusals register the + /// full reconstruction request rather than just its last increment. + pub fn reserve_process_shared( + &self, + component: ResidentMemoryComponentIdV1, + requested_bytes: NonZeroU64, + ) -> Result { + self.authority.pressure.measure_admission_bytes(); + self.authority.reserve_process_shared_attribution( + component, + requested_bytes, + Some(&self.identity), + ) + } + + /// The largest witnessed live-plus-next requirement, retained even after + /// every construction guard has unwound. It predicts no unvisited work. + /// Pressure growth refusals retain their typed watermark independently of + /// whether the caller's execution control reached a checkpoint. Callers + /// use the admission watermark for a checkpoint-only refusal and the hard + /// limit for a ledger growth refusal. + pub fn witnessed_reconstruction_requirement( + &self, + ceiling: u64, + ) -> Result< + Option, + ResidentMemoryReconstructionDemandFailureV1, + > { + let _state = self.authority.lock_state(); + let witnessed = self + .identity + .0 + .witnessed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if witnessed.required_bytes == 0 { + return Ok(None); + } + let demand = ResidentMemoryReconstructionDemandV1 { + authority: Arc::clone(&self.authority), + required_bytes: witnessed.required_bytes, + ceiling: ceiling.min(self.authority.limit_bytes()), + }; + let demand = match witnessed.pressure_refusal { + Some(pressure) => demand.merge(ResidentMemoryReconstructionDemandV1 { + authority: Arc::clone(&self.authority), + required_bytes: pressure.required_bytes, + ceiling: pressure.ceiling, + })?, + None => demand, + }; + Ok(Some(demand)) + } +} + +/// An immutable witnessed requirement for construction that was discarded. +/// This owns no live charge and makes no claim about the cause of RSS changes. +#[derive(Clone, Debug)] +pub struct ResidentMemoryReconstructionDemandV1 { + authority: Arc, + // A live total plus a refused increment can exceed u64 even though the + // ledger itself cannot. Preserve that impossible requirement losslessly. + required_bytes: u128, + ceiling: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ResidentMemoryReconstructionDemandFailureV1 { + #[error("discarded reservation belongs to another resident-memory authority")] + AuthorityMismatch, + #[error("discarded reservation was included more than once")] + DuplicateReservation, + #[error("witnessed reconstruction requirement overflowed")] + RequirementOverflow, +} + +impl ResidentMemoryReconstructionDemandV1 { + pub const fn required_bytes(&self) -> u128 { + self.required_bytes + } + + /// Narrow this requirement to the checkpoint's actual admission ceiling. + #[must_use] + pub fn below_ceiling(mut self, ceiling: u64) -> Self { + self.ceiling = self.ceiling.min(ceiling); + self + } + + /// Preserve both witnessed constraints across retries of unchanged input. + /// A larger request and a tighter ceiling are not independently combined: + /// retain the tightest permissible standing charge and the largest cgroup + /// headroom request, which is exactly their conjunction. + pub fn merge(self, other: Self) -> Result { + if !Arc::ptr_eq(&self.authority, &other.authority) { + return Err(ResidentMemoryReconstructionDemandFailureV1::AuthorityMismatch); + } + let Some(left_slack) = u128::from(self.ceiling).checked_sub(self.required_bytes) else { + return Ok(self); + }; + let Some(right_slack) = u128::from(other.ceiling).checked_sub(other.required_bytes) else { + return Ok(other); + }; + let required_bytes = self.required_bytes.max(other.required_bytes); + // min(slack) + max(required) cannot exceed the original ceiling of + // whichever requirement supplied max(required). + let ceiling = u64::try_from(required_bytes + left_slack.min(right_slack)) + .map_err(|_| ResidentMemoryReconstructionDemandFailureV1::RequirementOverflow)?; + Ok(Self { + authority: self.authority, + required_bytes, + ceiling, + }) + } + + /// Include exact still-owned guards before they unwind. The iterator must + /// contain each discarded reservation once, from this same authority. + pub fn including_discarded_reservations<'a>( + mut self, + reservations: impl IntoIterator, + ) -> Result { + let mut included = Vec::<&ResidentMemoryReservationV1>::new(); + for reservation in reservations { + if !Arc::ptr_eq(&self.authority, &reservation.authority) { + return Err(ResidentMemoryReconstructionDemandFailureV1::AuthorityMismatch); + } + if included.iter().any(|held| std::ptr::eq(*held, reservation)) { + return Err(ResidentMemoryReconstructionDemandFailureV1::DuplicateReservation); + } + self.required_bytes = self + .required_bytes + .checked_add(u128::from(reservation.reserved_bytes())) + .ok_or(ResidentMemoryReconstructionDemandFailureV1::RequirementOverflow)?; + included.push(reservation); + } + Ok(self) + } + + /// Re-measure and atomically either admit this witnessed requirement or + /// re-arm the existing canonical waiter. A ready request adds no waiter, + /// so repeatedly checking it cannot generate its own wake loop. + pub fn can_retry(&self) -> bool { + self.authority.pressure.measure_admission_bytes(); + // Match the pressure settler's lock order. Holding both locks closes + // ledger-release and sample-settlement races during registration. + let mut waiting = self + .authority + .pressure + .waiting + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut state = self.authority.lock_state(); + let headroom = self.authority.headroom_from_state(&state, self.ceiling); + if u128::from(headroom.available_bytes) >= self.required_bytes { + return true; + } + if let Ok(required_bytes) = u64::try_from(self.required_bytes) + && required_bytes <= self.ceiling + { + state + .waiting_requests + .entry(self.ceiling) + .and_modify(|held| *held = (*held).min(required_bytes)) + .or_insert(required_bytes); + let authority = Arc::downgrade(&self.authority); + if !waiting.iter().any(|held| held.ptr_eq(&authority)) { + waiting.push(authority); + } + self.authority + .pressure + .any_waiting + .store(true, Ordering::Release); + } + false + } +} + /// The single process ceiling. Callers share one pointer-identical `Arc`. pub struct ProcessResidentMemoryV1 { limit_bytes: NonZeroU64, @@ -1499,6 +1750,20 @@ impl fmt::Debug for ProcessResidentMemoryV1 { } impl ProcessResidentMemoryV1 { + /// Attribute related construction regions to one allocation, independently + /// of any project or generation label. Clones retain this exact identity. + #[must_use] + pub fn begin_operation(self: &Arc) -> ResidentMemoryOperationV1 { + ResidentMemoryOperationV1 { + authority: Arc::clone(self), + identity: ResidentMemoryOperationIdentityV1(Arc::new( + ResidentMemoryOperationAllocationV1 { + witnessed: Mutex::new(ResidentMemoryOperationWitnessV1::default()), + }, + )), + } + } + /// Production constructor: binds the one process-wide measured-RSS cell. pub fn new(limit_bytes: NonZeroU64) -> Self { let ledger = Self::with_pressure( @@ -1528,6 +1793,11 @@ impl ProcessResidentMemoryV1 { &self.pressure } + #[must_use] + pub const fn limit_bytes(&self) -> u64 { + self.limit_bytes.get() + } + /// Wait for `bytes` to fit below `ceiling` beside both the ledger and the /// measured process. The pressure cell's headroom epoch advances once it /// would, and every refusal this authority makes registers the same way. @@ -1587,7 +1857,7 @@ impl ProcessResidentMemoryV1 { key: ResidentMemoryKeyV1, requested_bytes: NonZeroU64, ) -> Result { - if let Some(failure) = self.observed_over_budget_refusal(requested_bytes) { + if let Some(failure) = self.observed_over_budget_refusal(requested_bytes, requested_bytes) { return Err(self.refused(failure)); } if let Some(reservation) = self.try_reserve(&key, requested_bytes) { @@ -1666,30 +1936,131 @@ impl ProcessResidentMemoryV1 { component: ResidentMemoryComponentIdV1, requested_bytes: NonZeroU64, ) -> Result { - if let Some(failure) = self.observed_over_budget_refusal(requested_bytes) { - return Err(self.refused(failure)); - } + self.reserve_process_shared_attribution(component, requested_bytes, None) + } + + fn reserve_process_shared_attribution( + self: &Arc, + component: ResidentMemoryComponentIdV1, + requested_bytes: NonZeroU64, + operation: Option<&ResidentMemoryOperationIdentityV1>, + ) -> Result { let mut state = self.lock_state(); + // The canonical live total cannot exceed used_bytes. Widen before + // adding the next request so an impossible reconstruction stays + // impossible even when its witnessed requirement exceeds u64. + let required_bytes = operation.map(|operation| { + let live_bytes = state + .process_shared_charges + .iter() + .filter(|((_, identity), _)| identity.as_ref() == Some(operation)) + .map(|(_, bytes)| u128::from(*bytes)) + .sum::(); + let required_bytes = live_bytes + u128::from(requested_bytes.get()); + let mut witnessed = operation + .0 + .witnessed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + witnessed.required_bytes = witnessed.required_bytes.max(required_bytes); + required_bytes + }); let next_used = state .used_bytes .checked_add(requested_bytes.get()) .filter(|next_used| *next_used <= self.limit_bytes.get()); - let Some(next_used) = next_used else { - let failure = self.admission_failure_from_used(state.used_bytes, requested_bytes); - drop(state); - return Err(self.refused(failure)); + // An attributed operation is one admission unit even when its regions + // grow a byte at a time. Legacy callers retain their delta-sized floor. + // An unrepresentable operation requirement necessarily exceeds the + // u64 ledger ceiling; refuse it without truncating its stored witness. + let pressure_request = match required_bytes { + Some(required_bytes) => u64::try_from(required_bytes).ok().and_then(NonZeroU64::new), + None => Some(requested_bytes), }; - state.used_bytes = next_used; - *state.process_shared_charges.entry(component).or_default() += requested_bytes.get(); + let failure = match pressure_request { + Some(pressure_request) => { + self.observed_over_budget_refusal(pressure_request, requested_bytes) + } + None => Some(self.admission_failure_from_used(state.used_bytes, requested_bytes)), + } + .or_else(|| { + next_used + .is_none() + .then(|| self.admission_failure_from_used(state.used_bytes, requested_bytes)) + }); + if let Some(failure) = failure { + let waiting_bytes = required_bytes.unwrap_or(u128::from(requested_bytes.get())); + let ceiling = match (operation, required_bytes, failure) { + ( + Some(operation), + Some(required_bytes), + ResidentMemoryAdmissionFailureV1::ObservedOverBudget { + high_watermark_bytes, + .. + }, + ) => { + let mut witnessed = operation + .0 + .witnessed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // This exact operation always uses the same authority and + // immutable pressure watermark. Keep the largest actual + // refusal at that bound, separately from its historical + // peak at the hard limit. + if witnessed + .pressure_refusal + .is_none_or(|held| required_bytes > held.required_bytes) + { + witnessed.pressure_refusal = + Some(ResidentMemoryOperationPressureWitnessV1 { + required_bytes, + ceiling: high_watermark_bytes, + }); + } + high_watermark_bytes + } + _ => failure.limit_bytes(), + }; + let register = if let Ok(waiting_bytes) = u64::try_from(waiting_bytes) + && waiting_bytes <= ceiling + { + state + .waiting_requests + .entry(ceiling) + .and_modify(|held| *held = (*held).min(waiting_bytes)) + .or_insert(waiting_bytes); + true + } else { + false + }; + drop(state); + if register { + self.pressure.register_waiting(self); + } + return Err(failure); + } + // No failure means the checked addition above fits the authority. + // Use the same proven bound rather than a second fallible admission. + state.used_bytes += requested_bytes.get(); + *state + .process_shared_charges + .entry((component, operation.cloned())) + .or_default() += requested_bytes.get(); Ok(ProcessSharedMemoryReservationV1 { authority: Arc::clone(self), component, + operation: operation.cloned(), reserved_bytes: requested_bytes.get(), }) } pub fn snapshot(&self) -> ResidentMemorySnapshotV1 { let state = self.lock_state(); + let mut shared = BTreeMap::::new(); + for ((component, _), bytes) in &state.process_shared_charges { + *shared.entry(*component).or_default() += *bytes; + } ResidentMemorySnapshotV1 { used_bytes: state.used_bytes, limit_bytes: self.limit_bytes.get(), @@ -1701,13 +2072,9 @@ impl ProcessResidentMemoryV1 { bytes: *bytes, }) .collect(), - process_shared_charges: state - .process_shared_charges - .iter() - .map(|(component, bytes)| ProcessSharedMemoryChargeV1 { - component: *component, - bytes: *bytes, - }) + process_shared_charges: shared + .into_iter() + .map(|(component, bytes)| ProcessSharedMemoryChargeV1 { component, bytes }) .collect(), } } @@ -1787,18 +2154,21 @@ impl ProcessResidentMemoryV1 { /// Only *new* admissions are refused. Nothing already reserved is revoked, /// shrunk, or released by this path: the reservation guards outlive /// pressure exactly as before. Nominal RSS must leave room under the hard - /// limit for the allocation; an existing pressure latch still clears when a + /// limit for the additional allocation: live operation bytes are already + /// represented by RSS. The full request controls floor eligibility and + /// names the refused work; an existing pressure latch still clears when a /// later sample falls to the low watermark. fn observed_over_budget_refusal( &self, requested_bytes: NonZeroU64, + additional_bytes: NonZeroU64, ) -> Option { let pressure = self.pressure.state(); let observed_bytes = pressure.observed_bytes()?; let high_watermark_bytes = self.pressure.high_watermark_bytes(); if requested_bytes.get() <= RESIDENT_MEMORY_PRESSURE_ADMISSION_FLOOR_BYTES_V1 || (!pressure.is_over_budget() - && requested_bytes.get() + && additional_bytes.get() <= self.pressure.limit_bytes().saturating_sub(observed_bytes)) { return None; @@ -1925,6 +2295,7 @@ impl ProcessResidentMemoryV1 { fn shrink_process_shared( &self, component: ResidentMemoryComponentIdV1, + operation: Option<&ResidentMemoryOperationIdentityV1>, reserved_bytes: u64, measured_bytes: u64, ) -> Result<(), ResidentMemoryAdjustmentFailureV1> { @@ -1940,26 +2311,33 @@ impl ProcessResidentMemoryV1 { } let mut state = self.lock_state(); state.used_bytes -= released_bytes; - if let Some(charge) = state.process_shared_charges.get_mut(&component) { + let key = (component, operation.cloned()); + if let Some(charge) = state.process_shared_charges.get_mut(&key) { *charge -= released_bytes; if *charge == 0 { - state.process_shared_charges.remove(&component); + state.process_shared_charges.remove(&key); } } self.ledger_released(state); Ok(()) } - fn release_process_shared(&self, component: ResidentMemoryComponentIdV1, reserved_bytes: u64) { + fn release_process_shared( + &self, + component: ResidentMemoryComponentIdV1, + operation: Option<&ResidentMemoryOperationIdentityV1>, + reserved_bytes: u64, + ) { if reserved_bytes == 0 { return; } let mut state = self.lock_state(); state.used_bytes -= reserved_bytes; - if let Some(charge) = state.process_shared_charges.get_mut(&component) { + let key = (component, operation.cloned()); + if let Some(charge) = state.process_shared_charges.get_mut(&key) { *charge -= reserved_bytes; if *charge == 0 { - state.process_shared_charges.remove(&component); + state.process_shared_charges.remove(&key); } } self.ledger_released(state); @@ -2044,6 +2422,7 @@ impl Drop for ResidentMemoryReservationV1 { pub struct ProcessSharedMemoryReservationV1 { authority: Arc, component: ResidentMemoryComponentIdV1, + operation: Option, reserved_bytes: u64, } @@ -2052,12 +2431,37 @@ impl ProcessSharedMemoryReservationV1 { self.reserved_bytes } + /// Admit growth on this exact component and operation, retaining one guard + /// for the whole region. Coalescing ownership never releases ledger bytes + /// or emits headroom; a refusal leaves the existing reservation intact. + pub fn try_grow( + &mut self, + additional: NonZeroU64, + ) -> Result<(), ResidentMemoryAdmissionFailureV1> { + self.authority.pressure().measure_admission_bytes(); + let mut growth = self.authority.reserve_process_shared_attribution( + self.component, + additional, + self.operation.as_ref(), + )?; + // Successful canonical admission bounds both guards by used_bytes, + // so their total fits u64. Keep overflow typed if that invariant fails. + let reserved_bytes = self + .reserved_bytes + .checked_add(growth.reserved_bytes) + .ok_or_else(|| self.authority.admission_failure(additional))?; + self.reserved_bytes = reserved_bytes; + growth.reserved_bytes = 0; + Ok(()) + } + pub fn shrink_to( &mut self, measured_bytes: u64, ) -> Result<(), ResidentMemoryAdjustmentFailureV1> { self.authority.shrink_process_shared( self.component, + self.operation.as_ref(), self.reserved_bytes, measured_bytes, )?; @@ -2078,8 +2482,11 @@ impl fmt::Debug for ProcessSharedMemoryReservationV1 { impl Drop for ProcessSharedMemoryReservationV1 { fn drop(&mut self) { - self.authority - .release_process_shared(self.component, self.reserved_bytes); + self.authority.release_process_shared( + self.component, + self.operation.as_ref(), + self.reserved_bytes, + ); } } diff --git a/crates/tracedecay-runtime-core/src/resident_memory/tests.rs b/crates/tracedecay-runtime-core/src/resident_memory/tests.rs index ad852b6e68..51f923e238 100644 --- a/crates/tracedecay-runtime-core/src/resident_memory/tests.rs +++ b/crates/tracedecay-runtime-core/src/resident_memory/tests.rs @@ -24,6 +24,707 @@ fn bytes(value: u64) -> NonZeroU64 { NonZeroU64::new(value).expect("test byte count is non-zero") } +fn operation_authority(limit: u64) -> Arc { + Arc::new(ProcessResidentMemoryV1::with_pressure( + bytes(limit), + Arc::new(ResidentMemoryPressureV1::with_sampler( + bytes(limit), + Arc::new(|| None), + )), + )) +} + +#[test] +fn shared_guard_growth_coalesces_ownership_without_releasing_headroom() { + let authority = operation_authority(100); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let mut outside = authority + .reserve_process_shared(component, bytes(20)) + .unwrap(); + let operation = authority.begin_operation(); + let mut owned = operation + .reserve_process_shared(component, bytes(40)) + .unwrap(); + let related = operation + .clone() + .reserve_process_shared(component, bytes(10)) + .unwrap(); + let foreign_operation = authority.begin_operation(); + let foreign = foreign_operation + .reserve_process_shared(component, bytes(5)) + .unwrap(); + + // Releasing a temporary growth guard would satisfy this waiter. Correct + // coalescing keeps those admitted bytes charged continuously. + authority.wait_for_headroom(20, 99); + let epoch = authority.pressure.subscribe_headroom(); + let before = *epoch.borrow(); + owned.try_grow(bytes(10)).unwrap(); + assert_eq!(*epoch.borrow(), before, "coalescing emits no headroom wake"); + assert_eq!(owned.reserved_bytes(), 50); + let grown = authority.snapshot(); + assert_eq!(grown.used_bytes, 85); + assert_eq!(grown.process_shared_charges.len(), 1); + assert_eq!(grown.process_shared_charge_for(component), 85); + assert_eq!( + operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap() + .required_bytes(), + 60, + "growth witnesses all related regions, excluding foreign and legacy guards" + ); + + assert!(owned.try_grow(bytes(20)).is_err()); + assert_eq!(owned.reserved_bytes(), 50); + assert_eq!( + authority.snapshot(), + grown, + "refusal preserves the existing charge" + ); + let demand = operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap(); + assert_eq!(demand.required_bytes(), 80); + owned.shrink_to(30).unwrap(); + assert_eq!(authority.snapshot().used_bytes, 65); + drop(owned); + assert_eq!(authority.snapshot().used_bytes, 35); + drop(related); + assert_eq!(authority.snapshot().used_bytes, 25); + assert!(!demand.can_retry()); + drop(foreign); + assert!(demand.can_retry()); + outside.try_grow(bytes(5)).unwrap(); + assert_eq!(outside.reserved_bytes(), 25); + assert_eq!( + authority.snapshot().process_shared_charge_for(component), + 25 + ); + drop(outside); + assert_eq!(authority.snapshot().used_bytes, 0); + assert_eq!(authority.snapshot().process_shared_charges, Vec::new()); +} + +#[test] +fn operation_growth_checks_nominal_rss_headroom_only_for_additional_bytes() { + const MIB: u64 = 1024 * 1024; + let sampled = Arc::new(AtomicU64::new(200 * MIB)); + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler(bytes(1000 * MIB), { + let sampled = Arc::clone(&sampled); + Arc::new(move || { + let observed = sampled.load(Ordering::Acquire); + Some(ProcessResidentSampleV1 { + resident_bytes: observed, + unreclaimable_bytes: observed, + swapped_bytes: 0, + cgroup_headroom_bytes: None, + }) + }) + })); + let authority = Arc::new(ProcessResidentMemoryV1::with_pressure( + bytes(1000 * MIB), + Arc::clone(&pressure), + )); + let operation = authority.begin_operation(); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let mut owned = operation + .reserve_process_shared(component, bytes(400 * MIB)) + .unwrap(); + // RSS now includes this region's 400 MiB beside the original 200 MiB. + sampled.store(600 * MIB, Ordering::Release); + owned.try_grow(bytes(20 * MIB)).unwrap(); + assert_eq!(owned.reserved_bytes(), 420 * MIB); + assert_eq!(authority.snapshot().used_bytes, 420 * MIB); + assert_eq!( + authority.snapshot().process_shared_charge_for(component), + 420 * MIB + ); + sampled.store(620 * MIB, Ordering::Release); + assert!(!pressure.sample_and_publish().unwrap().1.is_over_budget()); + + // An actual latch crossing still refuses a tiny increment on this large + // operation, even though that increment alone is below the admission floor. + sampled.store(950 * MIB, Ordering::Release); + assert!(!pressure.state().is_over_budget()); + let before = authority.snapshot(); + let refusal = owned.try_grow(bytes(1)).unwrap_err(); + assert!(refusal.is_observed_over_budget()); + assert_eq!(refusal.requested_bytes(), 420 * MIB + 1); + assert_eq!(owned.reserved_bytes(), 420 * MIB); + assert_eq!(authority.snapshot(), before); + let demand = operation + .witnessed_reconstruction_requirement(authority.limit_bytes()) + .unwrap() + .unwrap(); + assert_eq!(demand.required_bytes(), u128::from(420 * MIB) + 1); + assert_eq!(demand.ceiling, 900 * MIB); + owned.shrink_to(400 * MIB).unwrap(); + assert_eq!(authority.snapshot().used_bytes, 400 * MIB); + drop(owned); + assert_eq!(authority.snapshot().used_bytes, 0); + assert_eq!(authority.snapshot().process_shared_charges, Vec::new()); + sampled.store(200 * MIB, Ordering::Release); + assert!(demand.can_retry()); +} + +#[test] +fn shared_guard_tiny_growth_samples_pressure_and_retains_the_full_refusal_witness() { + let unit = RESIDENT_MEMORY_PRESSURE_ADMISSION_FLOOR_BYTES_V1; + let sampled = Arc::new(AtomicU64::new(0)); + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler(bytes(100 * unit), { + let sampled = Arc::clone(&sampled); + Arc::new(move || { + let observed = sampled.load(Ordering::Acquire); + Some(ProcessResidentSampleV1 { + resident_bytes: observed, + unreclaimable_bytes: observed, + swapped_bytes: 0, + cgroup_headroom_bytes: None, + }) + }) + })); + let authority = Arc::new(ProcessResidentMemoryV1::with_pressure( + bytes(100 * unit), + Arc::clone(&pressure), + )); + let operation = authority.begin_operation(); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let mut owned = operation + .reserve_process_shared(component, bytes(70 * unit)) + .unwrap(); + sampled.store(95 * unit, Ordering::Release); + assert!(!pressure.state().is_over_budget()); + let refusal = owned.try_grow(bytes(1)).unwrap_err(); + assert!(refusal.is_observed_over_budget()); + assert_eq!(refusal.requested_bytes(), 70 * unit + 1); + assert_eq!(owned.reserved_bytes(), 70 * unit); + assert_eq!(authority.snapshot().used_bytes, 70 * unit); + let demand = operation + .witnessed_reconstruction_requirement(100 * unit) + .unwrap() + .unwrap(); + assert_eq!(demand.required_bytes(), u128::from(70 * unit) + 1); + assert_eq!(demand.ceiling, 90 * unit); + assert_eq!( + authority.lock_state().waiting_requests.get(&(90 * unit)), + Some(&(70 * unit + 1)) + ); + drop(owned); + sampled.store(0, Ordering::Release); + assert!(demand.can_retry()); + assert_eq!(authority.snapshot().process_shared_charges, Vec::new()); +} + +#[test] +fn reconstruction_waits_for_its_whole_owned_request_after_unwind() { + let authority = operation_authority(100); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let outside = authority + .reserve_process_shared(component, bytes(20)) + .unwrap(); + let operation = authority.begin_operation(); + let owned = operation + .reserve_process_shared(component, bytes(70)) + .unwrap(); + assert!( + operation + .reserve_process_shared(component, bytes(20)) + .is_err() + ); + assert_eq!(authority.lock_state().waiting_requests.get(&100), Some(&90)); + let demand = operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap(); + assert_eq!(demand.required_bytes(), 90); + drop(owned); + assert!(!demand.can_retry()); + assert_eq!(authority.snapshot().used_bytes, 20); + drop(outside); + assert!(demand.can_retry()); + let epoch = authority.pressure.subscribe_headroom(); + let before = *epoch.borrow(); + for _ in 0..8 { + assert!(demand.can_retry()); + } + assert_eq!( + *epoch.borrow(), + before, + "ready checks cannot wake themselves" + ); + assert!(authority.lock_state().waiting_requests.is_empty()); + assert!(authority.lock_state().process_shared_charges.is_empty()); +} + +#[test] +fn reconstruction_includes_exact_discarded_worker_and_source_reservations() { + let authority = operation_authority(100); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let outside = authority + .reserve_process_shared(component, bytes(15)) + .unwrap(); + let worker = authority + .reserve(key("p", "w", "g", "worker"), bytes(6)) + .unwrap(); + let source = authority + .reserve(key("p", "w", "g", "source"), bytes(4)) + .unwrap(); + let operation = authority.begin_operation(); + let owned = operation + .reserve_process_shared(component, bytes(60)) + .unwrap(); + assert!( + operation + .reserve_process_shared(component, bytes(20)) + .is_err() + ); + let construction = operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap(); + assert_eq!(construction.required_bytes(), 80); + let demand = construction + .including_discarded_reservations([&worker, &source]) + .unwrap(); + assert_eq!(demand.required_bytes(), 90); + drop((worker, source, owned)); + assert!( + !demand.can_retry(), + "85 bytes cannot reconstruct the 90-byte operation" + ); + drop(outside); + assert!(demand.can_retry()); +} + +#[test] +fn discarded_reservations_require_exact_authority_and_distinct_guards() { + let authority = operation_authority(100); + let foreign = operation_authority(100); + let operation = authority.begin_operation(); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let _owned = operation + .reserve_process_shared(component, bytes(10)) + .unwrap(); + let demand = operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap(); + let worker = authority + .reserve(key("p", "w", "g", "worker"), bytes(5)) + .unwrap(); + let foreign_worker = foreign + .reserve(key("p", "w", "g", "worker"), bytes(5)) + .unwrap(); + assert!(matches!( + demand + .clone() + .including_discarded_reservations([&foreign_worker]), + Err(super::ResidentMemoryReconstructionDemandFailureV1::AuthorityMismatch) + )); + assert!(matches!( + demand.including_discarded_reservations([&worker, &worker]), + Err(super::ResidentMemoryReconstructionDemandFailureV1::DuplicateReservation) + )); +} + +#[test] +fn concurrent_operations_share_component_reporting_without_sharing_identity() { + let authority = operation_authority(100); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let first = authority.begin_operation(); + let second = authority.begin_operation(); + let related = first.clone(); + assert!(Arc::ptr_eq(first.authority(), &authority)); + let first_guard = first.reserve_process_shared(component, bytes(20)).unwrap(); + let mut related_guard = related + .reserve_process_shared(component, bytes(10)) + .unwrap(); + let second_guard = std::thread::spawn(move || { + let guard = second.reserve_process_shared(component, bytes(50)).unwrap(); + (second, guard) + }) + .join() + .unwrap(); + assert_eq!(authority.snapshot().process_shared_charges.len(), 1); + assert_eq!( + authority.snapshot().process_shared_charge_for(component), + 80 + ); + assert!(first.reserve_process_shared(component, bytes(25)).is_err()); + assert_eq!( + first + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap() + .required_bytes(), + 55 + ); + assert_eq!( + second_guard + .0 + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap() + .required_bytes(), + 50 + ); + related_guard.shrink_to(5).unwrap(); + drop(first_guard); + assert_eq!( + authority.snapshot().process_shared_charge_for(component), + 55 + ); + drop(second_guard); + assert_eq!(authority.snapshot().process_shared_charge_for(component), 5); + drop(related_guard); + assert!(authority.lock_state().process_shared_charges.is_empty()); +} + +#[test] +fn checkpoint_witness_survives_guard_drop_and_uses_its_narrower_ceiling() { + let authority = operation_authority(100); + let operation = authority.begin_operation(); + assert!( + operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .is_none() + ); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let mut guard = operation + .reserve_process_shared(component, bytes(70)) + .unwrap(); + guard.shrink_to(10).unwrap(); + drop(guard); + let demand = operation + .witnessed_reconstruction_requirement(authority.limit_bytes()) + .unwrap() + .unwrap(); + assert_eq!(demand.required_bytes(), 70); + let outside = authority + .reserve_process_shared(component, bytes(25)) + .unwrap(); + assert!( + demand.can_retry(), + "ledger refusal keeps the hard-limit ceiling" + ); + let checkpoint = demand.below_ceiling(authority.admission_watermark_bytes()); + assert_eq!(checkpoint.ceiling, 90); + assert!(!checkpoint.can_retry()); + assert_eq!(checkpoint.clone().below_ceiling(100).ceiling, 90); + drop(outside); + assert!(checkpoint.can_retry()); +} + +#[test] +fn unrelated_waiter_and_latch_wakes_do_not_admit_a_reconstruction() { + let authority = operation_authority(100); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let outside = authority + .reserve_process_shared(component, bytes(20)) + .unwrap(); + let operation = authority.begin_operation(); + let owned = operation + .reserve_process_shared(component, bytes(70)) + .unwrap(); + assert!( + operation + .reserve_process_shared(component, bytes(20)) + .is_err() + ); + let demand = operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap(); + drop(owned); + assert!(!demand.can_retry()); + let epoch = authority.pressure.subscribe_headroom(); + let before = *epoch.borrow(); + authority.wait_for_headroom(1, 100); + authority.pressure.publish_observed_resident_bytes(0); + assert!(*epoch.borrow() > before); + assert!(!demand.can_retry()); + authority.pressure.publish_observed_resident_bytes(95); + let before = *epoch.borrow(); + authority.pressure.publish_observed_resident_bytes(0); + assert!(*epoch.borrow() > before); + assert!(!demand.can_retry()); + assert_eq!(authority.lock_state().waiting_requests.get(&100), Some(&90)); + drop(outside); + assert!(demand.can_retry()); +} + +#[test] +fn reconstruction_registration_observes_releases_on_either_side_of_arming() { + let authority = operation_authority(100); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let outside = authority + .reserve_process_shared(component, bytes(20)) + .unwrap(); + let operation = authority.begin_operation(); + let owned = operation + .reserve_process_shared(component, bytes(70)) + .unwrap(); + assert!( + operation + .reserve_process_shared(component, bytes(20)) + .is_err() + ); + let demand = operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap(); + drop(owned); + assert!(!demand.can_retry()); + let epoch = authority.pressure.subscribe_headroom(); + let before = *epoch.borrow(); + drop(outside); + assert!( + *epoch.borrow() > before, + "a release after arming wakes the waiter" + ); + // Hold the settler lock so the concurrent checker cannot register before + // the release. Its locked headroom read must see the newly available room. + let outside = authority + .reserve_process_shared(component, bytes(20)) + .unwrap(); + let waiting = authority.pressure.waiting.lock().unwrap(); + let (started, received) = std::sync::mpsc::channel(); + let checker = std::thread::spawn(move || { + started.send(()).unwrap(); + demand.can_retry() + }); + received.recv().unwrap(); + drop(outside); + drop(waiting); + assert!(checker.join().unwrap()); + assert!(authority.lock_state().waiting_requests.is_empty()); +} + +#[test] +fn dropped_operations_leave_no_attributed_charges_or_growing_waiters() { + let authority = operation_authority(100); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let outside = authority + .reserve_process_shared(component, bytes(20)) + .unwrap(); + for _ in 0..32 { + let operation = authority.begin_operation(); + let identity = Arc::downgrade(&operation.identity.0); + let guard = operation + .reserve_process_shared(component, bytes(70)) + .unwrap(); + assert!( + operation + .reserve_process_shared(component, bytes(20)) + .is_err() + ); + let demand = operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap(); + assert!(!demand.can_retry()); + drop((operation, guard, demand)); + assert!( + identity.upgrade().is_none(), + "cancellation releases the exact operation identity" + ); + assert_eq!(authority.snapshot().used_bytes, 20); + assert_eq!(authority.lock_state().process_shared_charges.len(), 1); + assert_eq!(authority.lock_state().waiting_requests.len(), 1); + } + drop(outside); + assert!(authority.lock_state().process_shared_charges.is_empty()); + assert!(authority.lock_state().waiting_requests.is_empty()); + let waiting = authority.pressure.waiting.lock().unwrap(); + assert!( + waiting.len() <= 1, + "only the canonical authority may be retained weakly" + ); +} + +#[test] +fn observed_growth_refuses_tiny_large_operation_growth_and_preserves_independent_small_admissions() +{ + let unit = RESIDENT_MEMORY_PRESSURE_ADMISSION_FLOOR_BYTES_V1; + let authority = operation_authority(100 * unit); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let operation = authority.begin_operation(); + let owned = operation + .reserve_process_shared(component, bytes(10 * unit)) + .unwrap(); + authority + .pressure + .publish_observed_resident_bytes(95 * unit); + let error = operation + .reserve_process_shared(component, bytes(2 * unit)) + .unwrap_err(); + assert!(error.is_observed_over_budget()); + assert_eq!(error.requested_bytes(), 12 * unit); + assert_eq!( + authority.lock_state().waiting_requests.get(&(90 * unit)), + Some(&(12 * unit)) + ); + let before = authority.snapshot(); + let tiny = operation + .reserve_process_shared(component, bytes(1)) + .unwrap_err(); + assert!(tiny.is_observed_over_budget()); + assert_eq!(tiny.requested_bytes(), 10 * unit + 1); + assert_eq!( + authority.snapshot(), + before, + "tiny growth cannot bypass the operation floor" + ); + assert_eq!( + authority.lock_state().waiting_requests.get(&(90 * unit)), + Some(&(10 * unit + 1)) + ); + let independent = authority.begin_operation(); + let small = independent + .reserve_process_shared(component, bytes(unit)) + .unwrap(); + let legacy = authority + .reserve_process_shared(component, bytes(unit)) + .unwrap(); + assert_eq!(small.reserved_bytes(), unit); + assert_eq!(legacy.reserved_bytes(), unit); + assert_eq!( + operation + .witnessed_reconstruction_requirement(100 * unit) + .unwrap() + .unwrap() + .required_bytes(), + u128::from(12 * unit) + ); + drop((owned, small, legacy)); +} + +#[test] +fn growth_sampling_retains_pressure_constraint_before_any_control_checkpoint() { + let unit = RESIDENT_MEMORY_PRESSURE_ADMISSION_FLOOR_BYTES_V1; + let sampled = Arc::new(AtomicU64::new(10 * unit)); + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler(bytes(100 * unit), { + let sampled = Arc::clone(&sampled); + Arc::new(move || { + let observed = sampled.load(Ordering::Acquire); + Some(ProcessResidentSampleV1 { + resident_bytes: observed, + unreclaimable_bytes: observed, + swapped_bytes: 0, + cgroup_headroom_bytes: None, + }) + }) + })); + let authority = Arc::new(ProcessResidentMemoryV1::with_pressure( + bytes(100 * unit), + Arc::clone(&pressure), + )); + let operation = authority.begin_operation(); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let mut owned = operation + .reserve_process_shared(component, bytes(85 * unit)) + .unwrap(); + owned.shrink_to(70 * unit).unwrap(); + sampled.store(91 * unit, Ordering::Release); + assert!(!pressure.state().is_over_budget()); + let refusal = operation + .reserve_process_shared(component, bytes(10 * unit)) + .unwrap_err(); + assert!(matches!( + refusal, + ResidentMemoryAdmissionFailureV1::ObservedOverBudget { + high_watermark_bytes, .. + } if high_watermark_bytes == 90 * unit + )); + assert!(pressure.state().is_over_budget()); + assert_eq!( + authority.lock_state().waiting_requests.get(&(90 * unit)), + Some(&(80 * unit)) + ); + + drop(owned); + // No execution control or caller-supplied watermark participates: the + // operation must retain the constraint its own reserve just witnessed. + let demand = operation + .witnessed_reconstruction_requirement(authority.limit_bytes()) + .unwrap() + .unwrap(); + assert_eq!(demand.required_bytes(), u128::from(85 * unit)); + assert_eq!(demand.ceiling, 95 * unit, "85@100 conjoined with 80@90"); + sampled.store(12 * unit, Ordering::Release); + assert!( + authority + .headroom_below(authority.limit_bytes()) + .available_bytes + >= 85 * unit, + "the historical hard-limit requirement alone would authorize replay" + ); + assert!(!demand.can_retry()); + assert!( + !pressure.state().is_over_budget(), + "latch clearing is insufficient" + ); + + let epoch = pressure.subscribe_headroom(); + let before = *epoch.borrow(); + authority.wait_for_headroom(unit, authority.limit_bytes()); + pressure.sample_and_publish(); + assert!( + *epoch.borrow() > before, + "an unrelated request became feasible" + ); + assert!(!demand.can_retry()); + let before = *epoch.borrow(); + sampled.store(10 * unit, Ordering::Release); + pressure.sample_and_publish(); + assert!( + *epoch.borrow() > before, + "the actual reconstruction now fits" + ); + assert!(demand.can_retry()); + assert!( + 85 * unit > 90 * unit - sampled.load(Ordering::Acquire), + "max(bytes) at min(ceiling) would incorrectly keep this request parked" + ); + assert!(authority.lock_state().waiting_requests.is_empty()); +} + +#[test] +fn an_overflowing_reconstruction_requirement_stays_unretryable() { + let authority = operation_authority(u64::MAX); + let component = ResidentMemoryComponentIdV1::new("construction").unwrap(); + let operation = authority.begin_operation(); + let mut guard = operation + .reserve_process_shared(component, bytes(u64::MAX)) + .unwrap(); + assert!( + operation + .reserve_process_shared(component, bytes(1)) + .is_err() + ); + assert_eq!( + guard.try_grow(bytes(1)).unwrap_err(), + ResidentMemoryAdmissionFailureV1::ReservationCeiling { + used_bytes: u64::MAX, + requested_bytes: 1, + limit_bytes: u64::MAX, + } + ); + assert_eq!(guard.reserved_bytes(), u64::MAX); + let demand = operation + .witnessed_reconstruction_requirement(u64::MAX) + .unwrap() + .unwrap(); + assert_eq!(demand.required_bytes(), u128::from(u64::MAX) + 1); + drop(guard); + assert!(!demand.can_retry()); + assert!(authority.lock_state().waiting_requests.is_empty()); +} + fn cgroup_fixture( cgroup_membership: Option<&str>, memory_max: Option<&str>, @@ -498,6 +1199,23 @@ fn process_reservations_sum_every_production_ledger_until_released() { drop(two); drop(second); assert_eq!(reserved(COMPONENT), 0); + + let component = ResidentMemoryComponentIdV1::new(COMPONENT).unwrap(); + let first_operation = first.begin_operation(); + let second_operation = first.begin_operation(); + let mut first_charge = first_operation + .reserve_process_shared(component, bytes(7)) + .unwrap(); + let second_charge = second_operation + .reserve_process_shared(component, bytes(11)) + .unwrap(); + assert_eq!(reserved(COMPONENT), 18); + first_charge.try_grow(bytes(3)).unwrap(); + assert_eq!(reserved(COMPONENT), 21); + drop(first_charge); + assert_eq!(reserved(COMPONENT), 11); + drop(second_charge); + assert_eq!(reserved(COMPONENT), 0); } #[test] @@ -1854,3 +2572,51 @@ fn sized_admissions_share_the_watermark_without_charging_its_margin() { drop((reservations, maximum)); assert_eq!(authority.snapshot().used_bytes, 0); } + +#[test] +fn merged_reconstruction_requirements_preserve_each_admission_ceiling() { + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler( + bytes(100), + Arc::new(|| None), + )); + let authority = Arc::new(ProcessResidentMemoryV1::with_pressure(bytes(100), pressure)); + let operation = authority.begin_operation(); + let component = ResidentMemoryComponentIdV1::new("merged-construction").unwrap(); + let held = operation + .reserve_process_shared(component, bytes(80)) + .unwrap(); + let first = operation + .witnessed_reconstruction_requirement(90) + .unwrap() + .unwrap(); + let extra = operation + .reserve_process_shared(component, bytes(10)) + .unwrap(); + let second = operation + .witnessed_reconstruction_requirement(100) + .unwrap() + .unwrap(); + drop((held, extra)); + let merged = first.merge(second).unwrap(); + let outside = authority + .reserve( + key("project", "worktree", "generation", "outside"), + bytes(10), + ) + .unwrap(); + assert_eq!(merged.required_bytes(), 90); + assert!( + merged.can_retry(), + "both original requirements fit beside ten bytes" + ); + let more = authority + .reserve( + key("project", "worktree", "generation", "outside"), + bytes(1), + ) + .unwrap(); + assert!(!merged.can_retry()); + drop(more); + assert!(merged.can_retry()); + drop(outside); +} diff --git a/crates/tracedecay-search-eval/src/candidate_output.rs b/crates/tracedecay-search-eval/src/candidate_output.rs index c89bace230..c7eb78f3e5 100644 --- a/crates/tracedecay-search-eval/src/candidate_output.rs +++ b/crates/tracedecay-search-eval/src/candidate_output.rs @@ -17,6 +17,9 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, detected_process_resident_memory_limit_v1, +}; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -30,7 +33,7 @@ use tracedecay_code_index::production::{ CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, DAEMON_CODE_INDEX_CHUNKER_REVISION, MemorySealedPublicationStoreV1, SealedGenerationSegmentPublicationV1, - VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, + SharedDecodedContentPoolV1, VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalSymbolDisplayV1, }; use tracedecay_contracts::ResolvedScope; @@ -1312,6 +1315,9 @@ fn publish_scope_generation( profile_digest: lexical_projection_profile_digest()?, }; let request = CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot, captured_files: captured, changed_files: BTreeSet::new(), @@ -1345,9 +1351,17 @@ fn publish_scope_generation( .map_err(|error| { CandidateOutputError::Contract(format!("publish generation: {error}")) })?; - let generation = published.decoded().map(Arc::clone).ok_or_else(|| { - CandidateOutputError::Contract("a generation without a parent builds cold".to_owned()) - })?; + let reader = published.sealed_generation().segment_reader(); + let generation = Arc::new( + CodeIndexPublishedGenerationV1::decode_partitioned_sealed( + published.manifest_bytes(), + &SharedDecodedContentPoolV1::default(), + |request, buffer| reader(request, buffer), + ) + .map_err(|error| { + CandidateOutputError::Contract(format!("decode published generation: {error}")) + })?, + ); Ok(Some((generation, file_to_document))) } diff --git a/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs b/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs index a505c49553..eff54a990f 100644 --- a/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs +++ b/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs @@ -1,6 +1,9 @@ use std::fs; use std::path::Path; use std::{collections::BTreeSet, sync::Arc}; +use tracedecay_runtime_core::resident_memory::{ + ProcessResidentMemoryV1, detected_process_resident_memory_limit_v1, +}; use tracedecay_code_index::chunks::content_digest; use tracedecay_code_index::production::{ @@ -60,6 +63,9 @@ pub(super) fn prove_cancellation( omitted_sources: Vec::new(), }; let request = CodeIndexBuildRequestV1 { + resident_memory: std::sync::Arc::new(ProcessResidentMemoryV1::new( + detected_process_resident_memory_limit_v1(), + )), snapshot, captured_files: captured, changed_files: BTreeSet::new(), diff --git a/crates/tracedecay-source-edit/src/edits/mod.rs b/crates/tracedecay-source-edit/src/edits/mod.rs index 63b885dcb7..aaeea9e24b 100644 --- a/crates/tracedecay-source-edit/src/edits/mod.rs +++ b/crates/tracedecay-source-edit/src/edits/mod.rs @@ -10,6 +10,8 @@ mod symbols; #[cfg(test)] mod execute_tests; #[cfg(test)] +mod move_destination_tests; +#[cfg(test)] mod reconcile_tests; #[cfg(test)] mod test_support; diff --git a/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs b/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs new file mode 100644 index 0000000000..e953cb550e --- /dev/null +++ b/crates/tracedecay-source-edit/src/edits/move_destination_tests.rs @@ -0,0 +1,268 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use tempfile::tempdir; +use tracedecay_contracts::{ + EffectTermination, SourceEditKind, SourceEditRequest, source_edit_operation, +}; + +use super::test_support::{ + fixture_authorization, fixture_graph, fixture_request_for_edit, fixture_symbol_code_graph, +}; +use crate::execute::resolve_source_edit_preview; +use crate::{SourceEditOutcome, execute_source_edit, preview_source_edit_expected_state}; + +fn compile_move_destination_fixture(project: &Path) { + let output = Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())) + .current_dir(project) + .args([ + "--edition=2024", + "--crate-type=lib", + "--emit=metadata", + "--crate-name=move_destination", + "src/lib.rs", + "-o", + "fixture.rmeta", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "move destination must compile: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +async fn move_with_destination_header( + before_import: &str, + after_import: &str, + newline: &str, + destination_exists: bool, +) { + let project = tempdir().unwrap(); + fs::create_dir(project.path().join("src")).unwrap(); + let moved_text = "pub fn moved(value: Widget) -> Widget {\n value\n}"; + let source = format!("use crate::model::Widget;\n\n{moved_text}\n"); + let destination = format!("{before_import}{after_import}"); + fs::write(project.path().join("src/source.rs"), &source).unwrap(); + fs::write(project.path().join("src/model.rs"), "pub struct Widget;\n").unwrap(); + fs::write( + project.path().join("src/lib.rs"), + "#![allow(dead_code, unused_imports)]\nmod model;\nmod source;\nmod relocated;\n", + ) + .unwrap(); + if destination_exists { + fs::write(project.path().join("src/relocated.rs"), &destination).unwrap(); + compile_move_destination_fixture(project.path()); + } + let (graph, _) = fixture_graph(project.path()).await; + let code_graph = fixture_symbol_code_graph( + "src/source.rs", + &source, + moved_text, + "moved", + "source::moved", + ); + let mut request = fixture_request_for_edit( + SourceEditRequest::MoveSymbol { + symbol: "source::moved".to_owned(), + dest_file: "src/relocated.rs".to_owned(), + dry_run: false, + update_references: false, + }, + "source-edit.move-destination", + ); + let preview = resolve_source_edit_preview( + &graph, + &code_graph, + &request.context, + request.observed_at, + tracedecay_graph_query::request_graph_cancellation(&request.context), + request.edit.clone(), + ) + .await + .unwrap(); + let SourceEditOutcome::Move(result) = &preview.outcome else { + panic!("expected a move preview"); + }; + assert!(result.success, "{}", result.message); + assert_eq!(result.applied_imports, ["use crate::model::Widget;"]); + assert_eq!(preview.planned_files.len(), 2); + assert_eq!( + fs::read_to_string(project.path().join("src/source.rs")).unwrap(), + source + ); + if destination_exists { + assert_eq!( + fs::read_to_string(project.path().join("src/relocated.rs")).unwrap(), + destination + ); + } else { + assert!(!project.path().join("src/relocated.rs").exists()); + } + request.expected_state = preview.expected_state.unwrap(); + let authorization = fixture_authorization(&request); + let applied = execute_source_edit( + &graph, + &code_graph, + &source_edit_operation(SourceEditKind::MoveSymbol).unwrap(), + request, + &authorization, + ) + .await + .unwrap(); + assert!(applied.outcome.success(), "{}", applied.outcome.message()); + for planned in preview.planned_files { + assert_eq!( + fs::read_to_string(project.path().join(&planned.relative_path)).unwrap(), + planned.intended, + "apply must publish the exact preview for {}", + planned.relative_path + ); + } + compile_move_destination_fixture(project.path()); + let actual = fs::read_to_string(project.path().join("src/relocated.rs")).unwrap(); + let unchanged_destination = + format!("{before_import}use crate::model::Widget;{newline}{after_import}"); + assert!(actual.starts_with(&unchanged_destination), "{actual:?}"); + assert!(actual.ends_with(&format!("{moved_text}\n"))); + let mut parser = tree_sitter::Parser::new(); + let language = tracedecay_code_extraction::ts_provider::try_language("rust").unwrap(); + parser.set_language(&language).unwrap(); + let tree = parser.parse(&actual, None).unwrap(); + assert!(!tree.root_node().has_error()); + assert!( + tree.root_node() + .named_children(&mut tree.walk()) + .any(|node| { + node.kind() == "use_declaration" + && node.utf8_text(actual.as_bytes()).unwrap() == "use crate::model::Widget;" + }), + "the dependency must be at the destination module root" + ); +} + +#[tokio::test] +async fn move_symbol_imports_preserve_destination_syntax_in_preview_and_apply() { + for (before, after) in [ + ( + "use std::{\n fmt::{self, Debug as DebugTrait},\n collections::HashMap,\n};\n", + "pub fn marker() {}\n", + ), + ( + "//! Destination module.\n#![allow(dead_code)]\n/*! Inner block docs. */\n#![allow(unused_imports)]\nuse std::fmt::Debug;\n", + "pub fn marker() {}\n", + ), + ( + "// Header.\nuse std::fmt::Debug; // Existing import.\n\n", + "/// Marker documentation.\n#[inline]\npub fn marker() {}\n", + ), + ( + "", + "#[cfg(any())]\nuse missing_dependency::{Missing, AlsoMissing};\n/** Marker docs. */\n#[inline]\npub fn marker() {}\n", + ), + ( + "", + "mod nested {\n use std::{fmt::Debug, collections::HashMap};\n}\npub fn marker() {}\n", + ), + ( + "\u{feff}#!/usr/bin/env rust-script\n//! Destination module.\n", + "pub fn marker() {}\n", + ), + ] { + move_with_destination_header(before, after, "\n", true).await; + } +} + +#[tokio::test] +async fn move_symbol_preserves_destination_bytes_when_adding_imports() { + move_with_destination_header( + "//! Module ☃.\r\nuse std::fmt::Debug;\r\n\r\n", + "/// Marker.\r\n#[inline]\r\npub fn marker() {}\r\n \t\r\n\r\n", + "\r\n", + true, + ) + .await; + move_with_destination_header("\r\n \t\r\n", "", "\r\n", true).await; + move_with_destination_header("", "", "\n", true).await; + move_with_destination_header("", "", "\n", false).await; +} + +#[tokio::test] +async fn move_symbol_refuses_unparseable_destination_before_preview_or_apply() { + for (destination, dependency) in [ + ("use std::{\n", true), + ("pub fn marker() {\n", false), + ("/* unclosed destination comment", true), + ("/// Detached documentation.\n#[inline]\n", false), + ] { + let project = tempdir().unwrap(); + fs::create_dir(project.path().join("src")).unwrap(); + let moved_text = if dependency { + "pub fn moved(value: Widget) -> Widget { value }" + } else { + "pub fn moved() {}" + }; + let source = format!("use crate::model::Widget;\n\n{moved_text}\n"); + fs::write(project.path().join("src/source.rs"), &source).unwrap(); + fs::write(project.path().join("src/relocated.rs"), destination).unwrap(); + let (graph, _) = fixture_graph(project.path()).await; + let code_graph = fixture_symbol_code_graph( + "src/source.rs", + &source, + moved_text, + "moved", + "source::moved", + ); + let request = fixture_request_for_edit( + SourceEditRequest::MoveSymbol { + symbol: "source::moved".to_owned(), + dest_file: "src/relocated.rs".to_owned(), + dry_run: false, + update_references: false, + }, + "source-edit.move-invalid-destination", + ); + let preview = preview_source_edit_expected_state( + &graph, + &code_graph, + &request.context, + request.observed_at, + request.edit.clone(), + ) + .await; + assert!( + preview.is_err(), + "malformed destination must not get an accepted preview" + ); + let authorization = fixture_authorization(&request); + let applied = execute_source_edit( + &graph, + &code_graph, + &source_edit_operation(SourceEditKind::MoveSymbol).unwrap(), + request, + &authorization, + ) + .await + .unwrap(); + assert!(!applied.outcome.success()); + assert_eq!( + applied.effect.unwrap().receipt.outcome, + EffectTermination::Failed + ); + assert_eq!( + fs::read_to_string(project.path().join("src/source.rs")).unwrap(), + source + ); + assert_eq!( + fs::read_to_string(project.path().join("src/relocated.rs")).unwrap(), + destination + ); + assert!( + !crate::journal::SourceEditDurability::for_graph(&graph) + .journal_path() + .exists() + ); + } +} diff --git a/crates/tracedecay-source-edit/src/edits/preview.rs b/crates/tracedecay-source-edit/src/edits/preview.rs index ab3c938780..d2227df442 100644 --- a/crates/tracedecay-source-edit/src/edits/preview.rs +++ b/crates/tracedecay-source-edit/src/edits/preview.rs @@ -1,13 +1,12 @@ //! Dry-run preview rendering shared by every edit primitive: a bounded //! single-hunk diff of the changed region, the success-message wrapper that -//! marks dry runs, and the line classifier shared by `move_symbol`'s header -//! scanner and inner-doc skip loop. +//! marks dry runs, and the line classifier used by `move_symbol`'s source +//! inner-doc skip loop. /// What a single source line looks like when scanning a file's leading -/// region. One classifier shared by every place that used to hand-roll its -/// own prefix checks in `move_symbol`'s import-insertion header scanner (which -/// leading lines belong to the header vs. the first item) and its `//!` skip -/// loop (never let a moved span start on an inner module doc). +/// region. MoveSymbol's source-span `//!` skip loop uses this classifier to +/// keep an inner module doc out of the moved item. Destination insertion uses +/// Rust syntax and byte ranges instead. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum LeadingKind { /// Empty (after trimming leading whitespace). diff --git a/crates/tracedecay-source-edit/src/move_symbol/mod.rs b/crates/tracedecay-source-edit/src/move_symbol/mod.rs index f8aea3b615..9d5b50c0f4 100644 --- a/crates/tracedecay-source-edit/src/move_symbol/mod.rs +++ b/crates/tracedecay-source-edit/src/move_symbol/mod.rs @@ -215,7 +215,8 @@ pub(crate) async fn move_symbol( &dest_original, dest_module.as_deref(), ); - let dest_modified = build_dest_content(&dest_original, &applied_imports, &moved_text); + let dest_modified = + build_dest_content(&dest_rel, &dest_original, &applied_imports, &moved_text)?; let mut impact = analysis.hints; impact.extend( diff --git a/crates/tracedecay-source-edit/src/move_symbol/rendering.rs b/crates/tracedecay-source-edit/src/move_symbol/rendering.rs index b0ccc0854c..099b1c66dd 100644 --- a/crates/tracedecay-source-edit/src/move_symbol/rendering.rs +++ b/crates/tracedecay-source-edit/src/move_symbol/rendering.rs @@ -6,10 +6,10 @@ use std::collections::HashSet; -use crate::edits::{ - LeadingKind, MAX_PREVIEW_DIFF_LINES, PREVIEW_DIFF_CONTEXT, bounded_region_diff, - classify_leading_line, splice_lines, -}; +use tracedecay_code_extraction::ts_provider::parse_extractor_source; +use tracedecay_domain::errors::{Result, TraceDecayError}; + +use crate::edits::{MAX_PREVIEW_DIFF_LINES, PREVIEW_DIFF_CONTEXT, bounded_region_diff}; /// Removes `lines[start..=end]` and collapses the blank-line separator the /// removed item left behind so the source stays tidy. @@ -35,51 +35,115 @@ pub(super) fn remove_span_with_cleanup(lines: &[&str], start: usize, end: usize) /// import region or at the top of a fresh file), then the moved block after a /// blank-line separator. pub(super) fn build_dest_content( + dest_file: &str, dest_original: &str, imports: &[String], moved_text: &str, -) -> String { +) -> Result { let moved_block = moved_text.trim_end_matches('\n'); - if dest_original.trim().is_empty() { - let mut parts: Vec = Vec::new(); - if !imports.is_empty() { - parts.push(imports.join("\n")); - } - parts.push(moved_block.to_string()); - let mut content = parts.join("\n\n"); - content.push('\n'); - content + let mut content = if dest_file.ends_with(".rs") { + // Even a dependency-free move must not append inside malformed syntax + // or adopt dangling outer attributes from the destination. + insert_imports(dest_original, imports)? + } else if imports.is_empty() { + dest_original.to_owned() } else { - let with_imports = insert_imports(dest_original, imports); - format!("{}\n\n{}\n", with_imports.trim_end(), moved_block) + return Err(destination_syntax_error( + "automatic dependency imports require a Rust destination", + )); + }; + let newline = line_ending(dest_original); + if !content.is_empty() { + if !content.ends_with('\n') { + content.push_str(newline); + } + if !content.ends_with(&format!("{newline}{newline}")) { + content.push_str(newline); + } } + content.push_str(moved_block); + content.push('\n'); + Ok(content) } -/// Inserts `imports` into an existing Rust file after its leading module-doc / -/// comment / existing-`use` region. -pub(super) fn insert_imports(dest_source: &str, imports: &[String]) -> String { +/// Inserts imports between complete top-level Rust items, preserving all +/// existing bytes and the attachments of outer docs and attributes. +pub(super) fn insert_imports(dest_source: &str, imports: &[String]) -> Result { + let offset = rust_import_offset(dest_source)?; if imports.is_empty() { - return dest_source.to_string(); + return Ok(dest_source.to_string()); + } + let newline = line_ending(dest_source); + let (before, after) = dest_source.split_at(offset); + let mut content = before.to_owned(); + if !before.is_empty() && !before.ends_with('\n') { + content.push_str(newline); + } + content.push_str(&imports.join(newline)); + content.push_str(newline); + content.push_str(after); + Ok(content) +} + +fn line_ending(source: &str) -> &'static str { + if source + .find('\n') + .is_some_and(|offset| offset > 0 && source.as_bytes()[offset - 1] == b'\r') + { + "\r\n" + } else { + "\n" } - let lines: Vec<&str> = dest_source.lines().collect(); - let mut idx = 0; - while idx < lines.len() { - // Stop before an OUTER doc-comment block (`///` or `/**`): it documents - // the first item, and inserting a `use` between the doc and its item - // detaches the doc. Inner docs (`//!`), plain comments (`//`), blank - // lines, and existing imports stay in the header region. - match classify_leading_line(lines[idx]) { - LeadingKind::OuterDoc => break, - LeadingKind::Blank | LeadingKind::InnerDoc | LeadingKind::LineComment => idx += 1, - LeadingKind::UseImport => idx += 1, - LeadingKind::BlockComment | LeadingKind::Attribute | LeadingKind::Code => break, +} + +fn destination_syntax_error(message: impl Into) -> TraceDecayError { + TraceDecayError::project_route("source-edit-destination-syntax-unsupported", false, message) +} + +fn rust_import_offset(source: &str) -> Result { + let tree = parse_extractor_source("rust", "Rust", source).map_err(|error| { + destination_syntax_error(format!("cannot parse Rust destination: {error}")) + })?; + let root = tree.root_node(); + if root.has_error() { + return Err(destination_syntax_error( + "Rust destination contains incomplete or unsupported syntax", + )); + } + let mut offset = None; + let mut seen_item = false; + let mut pending_outer = false; + for node in root.named_children(&mut root.walk()) { + let comment = matches!(node.kind(), "line_comment" | "block_comment"); + if node.kind() == "inner_attribute_item" + || (comment && node.child_by_field_name("inner").is_some()) + { + if seen_item || pending_outer { + return Err(destination_syntax_error( + "Rust destination has an inner attribute or doc after an item or outer attribute", + )); + } + } else if node.kind() == "attribute_item" + || (comment && node.child_by_field_name("outer").is_some()) + { + // Outer attachments belong to the following item, including when + // that item is itself an import. Never split this group. + offset.get_or_insert(node.start_byte()); + pending_outer = true; + } else if !comment && node.kind() != "shebang" { + if !matches!(node.kind(), "use_declaration" | "extern_crate_declaration") { + offset.get_or_insert(node.start_byte()); + } + seen_item = true; + pending_outer = false; } } - let mut rebuilt: Vec<&str> = Vec::with_capacity(lines.len() + imports.len()); - rebuilt.extend_from_slice(&lines[..idx]); - rebuilt.extend(imports.iter().map(String::as_str)); - rebuilt.extend_from_slice(&lines[idx..]); - splice_lines(&rebuilt, dest_source.ends_with('\n')) + if pending_outer { + return Err(destination_syntax_error( + "Rust destination ends with an outer attribute or doc without its item", + )); + } + Ok(offset.unwrap_or(source.len())) } /// Drops imports already present verbatim in the destination and de-duplicates @@ -193,10 +257,12 @@ mod tests { #[test] fn build_dest_content_new_file_prepends_imports() { let content = build_dest_content( + "src/relocated.rs", "", &["use crate::pricing::LineItem;".to_string()], "/// doc\nfn f() {}\n", - ); + ) + .unwrap(); assert_eq!( content, "use crate::pricing::LineItem;\n\n/// doc\nfn f() {}\n" @@ -205,7 +271,13 @@ mod tests { #[test] fn build_dest_content_existing_file_appends_after_blank() { - let content = build_dest_content("//! mod\n\nfn existing() {}\n", &[], "fn f() {}"); + let content = build_dest_content( + "src/relocated.rs", + "//! mod\n\nfn existing() {}\n", + &[], + "fn f() {}", + ) + .unwrap(); assert_eq!(content, "//! mod\n\nfn existing() {}\n\nfn f() {}\n"); } @@ -214,7 +286,8 @@ mod tests { let out = insert_imports( "//! module doc\n\nfn a() {}\n", &["use crate::X;".to_string()], - ); + ) + .unwrap(); assert_eq!(out, "//! module doc\n\nuse crate::X;\nfn a() {}\n"); } @@ -225,7 +298,8 @@ mod tests { let out = insert_imports( "/// Docs for other.\npub fn other() {}\n", &["use crate::X;".to_string()], - ); + ) + .unwrap(); assert_eq!( out, "use crate::X;\n/// Docs for other.\npub fn other() {}\n" @@ -239,6 +313,63 @@ mod tests { assert_eq!(lines[doc + 1].trim(), "pub fn other() {}"); } + #[test] + fn insert_imports_preserves_comments_and_complete_same_line_items() { + for (original, expected) in [ + ( + "/* License. */\nuse a::{B, C}; /* Import note. */\nfn marker() {}", + "/* License. */\nuse a::{B, C}; /* Import note. */\nuse crate::X;\nfn marker() {}", + ), + ( + "use a::B; fn marker() {}", + "use a::B; \nuse crate::X;\nfn marker() {}", + ), + ("// License.", "// License.\nuse crate::X;\n"), + ("/* License. */", "/* License. */\nuse crate::X;\n"), + ("//! Module.", "//! Module.\nuse crate::X;\n"), + ] { + assert_eq!( + insert_imports(original, &["use crate::X;".to_owned()]).unwrap(), + expected + ); + } + } + + #[test] + fn destination_validation_rejects_incomplete_or_misplaced_attachments() { + for original in [ + "use a::{B, C", + "/// Detached.\n", + "#[allow(unused)]\n// Detached.\n", + "use a::B;\n#![allow(unused)]\n", + "fn keep() {}\n//! Misplaced module doc.\n", + ] { + assert!( + build_dest_content("src/relocated.rs", original, &[], "fn moved() {}").is_err(), + "must refuse without needing new imports: {original}" + ); + } + } + + #[test] + fn non_rust_destination_without_imports_keeps_generic_append_support() { + let original = "export const keep = 1;\n \t"; + assert_eq!( + build_dest_content("src/relocated.ts", original, &[], "export const moved = 2;") + .unwrap(), + "export const keep = 1;\n \t\n\nexport const moved = 2;\n" + ); + assert!( + build_dest_content( + "src/relocated.ts", + original, + &["use crate::X;".to_owned()], + "export const moved = 2;" + ) + .is_err() + ); + } + #[test] fn dedup_preserve_skips_existing() { let out = dedup_preserve(