From 8c2d2fa79cf8d84e50201472ced49f02d260ae7e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:24:07 -0700 Subject: [PATCH 1/8] test(code-index): pin a cold build refused at the watermark A cold build the resident-memory checkpoint stops gives back what it built. The latch clearing behind that release must not wake its own retry; memory another holder gives back must. Refs #4505 Co-Authored-By: Claude Opus 5.5 --- .../code_index_scheduler/tests/residency.rs | 105 +++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) 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..f2078f8439 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,6 +1,7 @@ use std::num::NonZeroU64; use std::path::Path; -use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock, Weak}; use std::time::{Duration, Instant}; use tempfile::TempDir; @@ -1427,6 +1428,108 @@ 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 +} + +/// Issue #4505: a cold build the watermark stops gives back what it built, +/// and the latch clearing behind that release is no memory anyone else gave +/// back. Retrying on it rebuilt the same refused pass forever. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_cold_build_refused_at_the_watermark_retries_only_on_memory_it_did_not_give_back() { + const GIB: u64 = 1024 * 1024 * 1024; + let fixture = GitFixture::new(&[("src/main.rs", "fn main() {}\n")]); + let store = TempDir::new().expect("store root"); + let limit = NonZeroU64::new(16 * GIB).unwrap(); + // The process outside the build, plus what the build adds while it holds + // admitted bytes: from 11.5 GiB it crosses the 14.4 GiB high watermark, + // and once it drops them the process is back under the 12 GiB low one. + let outside = Arc::new(AtomicU64::new(11 * GIB + GIB / 2)); + let ledger = Arc::new(OnceLock::>::new()); + let sampler = { + let outside = Arc::clone(&outside); + let ledger = Arc::clone(&ledger); + Arc::new(move || { + let building = ledger + .get() + .and_then(Weak::upgrade) + .is_some_and(|ledger| ledger.snapshot().used_bytes > 0); + let bytes = outside.load(Ordering::Acquire) + if building { 3 * GIB } else { 0 }; + Some(ProcessResidentSampleV1 { + resident_bytes: bytes, + unreclaimable_bytes: bytes, + swapped_bytes: 0, + cgroup_headroom_bytes: None, + }) + }) + }; + let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler(limit, sampler)); + let resident_memory = Arc::new(ProcessResidentMemoryV1::with_pressure( + limit, + Arc::clone(&pressure), + )); + ledger.set(Arc::downgrade(&resident_memory)).unwrap(); + pressure.sample_and_publish(); + let owners = Arc::new(ResidentOwnersV1::new(IDLE_WINDOW)); + let registry = CodeIndexSchedulerRegistryV1::with_resident_memory(1, resident_memory) + .with_resident_owners(Arc::clone(&owners)); + registry + .mount_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + ) + .await + .expect("mount daemon-owned scheduler"); + let blocked = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Some(reason) = registry + .dashboard_freshness(fixture.path()) + .await + .and_then(|freshness| freshness.parked) + .and_then(|parked| parked.blocked_reason) + { + return reason; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("the cold build parks on resident memory"); + assert_eq!(blocked, CodeIndexBuildBlockedReasonV1::ResidentMemory); + wait_for_worker_phase(®istry, fixture.path(), CodeIndexWorkerPhaseV1::Parked).await; + + // The build's own memory is gone and the latch clears. + let refused = owner_passes_started(®istry, fixture.path()).await; + assert!(!pressure.sample_and_publish().unwrap().1.is_over_budget()); + tokio::time::sleep(Duration::from_secs(2)).await; + assert_eq!( + owner_passes_started(®istry, fixture.path()).await, + refused, + "the refused build's own release is not headroom for its retry" + ); + assert!( + registry + .latest_text_serving_for_root(fixture.path()) + .await + .is_none() + ); + + // Something else gave memory back: the process now measures below where + // the refused build started, so the build fits and publishes. + outside.store(11 * GIB, Ordering::Release); + pressure.sample_and_publish(); + let text = wait_for_queryable_text_generation(®istry, fixture.path()).await; + assert!(text.query_owners_are_ready()); + + 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 From 3434b35c871fc802b3fa4cf9d233e33382390f2c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:47:12 -0700 Subject: [PATCH 2/8] fix(code-index): stop a refused cold pass from waking its own retry A cold reconcile the resident-memory checkpoint stops drops what it built. RSS falls under the low watermark, the latch clears, and the admission headroom epoch advances. Both the worker loop and the headroom watcher read that epoch as memory given back, so the pass rebuilt and was refused again on every maintenance sample. Record where measured RSS stood when such a pass began. Admission headroom now retries it only once the process measures below that point, and the bound registers with the resident-memory authority so a later sample under it still advances the epoch. Ledger refusals and passes admitted while already over the watermark keep retrying on the latch as before; owner releases by others still wake the retry. Fixes #4505 Co-Authored-By: Claude Opus 5.5 --- .../src/code_index_scheduler/registry.rs | 76 +++++++++++++++++++ .../code_index_scheduler/registry/mount.rs | 21 ++++- 2 files changed, 96 insertions(+), 1 deletion(-) 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..b016264218 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,7 @@ use super::{ }; use tracedecay_runtime_core::native_runtime; use tracedecay_runtime_core::path_safety::canonical_existing_identity; +use tracedecay_runtime_core::resident_memory::ResidentMemoryPressureStateV1; #[cfg(test)] mod cold_read_wake_tests; @@ -1691,6 +1692,81 @@ impl MemoryRefusalRetryV1 { } } +/// The measured process size a reconcile refused for resident memory started +/// from: it needed more headroom than that left it. +/// +/// Its own release returns the process to about where it started, and the +/// watermark latch clearing behind that release advances the admission +/// headroom epoch. Retrying on that epoch rebuilt the same refused pass +/// forever (issue #4505), so admission headroom retries the pass only once +/// the process measures below this. The bound registers with the authority, +/// so a sample under it advances the epoch even with the latch already clear. +struct RefusedReconcileStartV1(AtomicU64); + +impl Default for RefusedReconcileStartV1 { + fn default() -> Self { + Self(AtomicU64::new(u64::MAX)) + } +} + +impl RefusedReconcileStartV1 { + /// A pass that began under `started` was refused for memory. Only a pass + /// that itself carried the process over the watermark is bounded: a + /// ledger refusal, or one admitted while already over, is lifted by the + /// release or the latch clearing it waits on. + fn refused( + &self, + started: ResidentMemoryPressureStateV1, + resident_memory: &Arc, + ) { + if started.is_over_budget() || !resident_memory.pressure().state().is_over_budget() { + return; + } + if let Some(started_bytes) = started.observed_bytes() { + self.0.fetch_min(started_bytes, Ordering::AcqRel); + self.register(resident_memory); + } + } + + fn clear(&self) { + self.0.store(u64::MAX, Ordering::Release); + } + + /// Whether the process now measures below where the refused pass + /// started. Waiters settle together, so an epoch another waiter settled + /// re-registers this bound before it reads as no. + fn admits_retry( + &self, + resident_memory: &Arc, + ) -> bool { + let started_bytes = self.0.load(Ordering::Acquire); + if started_bytes == u64::MAX { + return true; + } + let admits = resident_memory + .pressure() + .state() + .observed_bytes() + .is_none_or(|observed| observed < started_bytes); + if !admits { + self.register(resident_memory); + } + admits + } + + fn register(&self, resident_memory: &Arc) { + let started_bytes = self.0.load(Ordering::Acquire); + if started_bytes == u64::MAX { + return; + } + let ceiling = resident_memory.pressure().limit_bytes(); + resident_memory.wait_for_headroom( + ceiling.saturating_sub(started_bytes.saturating_sub(1)), + ceiling, + ); + } +} + /// Optional derivation yields to source work accepted after this pass began. /// Neutral query wakes leave the source epoch unchanged. struct AttributionPreparationControlV1 { 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..28b6e405fd 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 @@ -729,6 +729,8 @@ impl CodeIndexSchedulerRegistryV1 { let worker_pending_wake = Arc::clone(&pending_wake); let memory_retry = Arc::new(super::MemoryRefusalRetryV1::default()); let worker_memory_retry = Arc::clone(&memory_retry); + let refused_reconcile_start = Arc::new(super::RefusedReconcileStartV1::default()); + let worker_refused_reconcile_start = Arc::clone(&refused_reconcile_start); let worker_cadence_telemetry = Arc::clone(&self.cadence_telemetry); let worker_shutting_down = Arc::clone(&shutting_down); let worker_build_publication_lock = Arc::clone(&build_publication_lock); @@ -804,6 +806,7 @@ impl CodeIndexSchedulerRegistryV1 { .park_headroom .released_by_others(&mut worker_owner_headroom); let mut worker_admission_headroom = self.resident_memory.pressure().subscribe_headroom(); + let worker_resident_memory = Arc::clone(&self.resident_memory); // Boxed at definition on purpose: this worker's state machine is the // largest future in the daemon (reconcile + text advance + decode + // activation + swap inline), and an unboxed `let` materializes the @@ -909,7 +912,8 @@ impl CodeIndexSchedulerRegistryV1 { .park_headroom .released_by_others(&mut worker_owner_headroom) > worker_owner_headroom_seen - || worker_admission_headroom.has_changed(); + || (worker_admission_headroom.has_changed() + && worker_refused_reconcile_start.admits_retry(&worker_resident_memory)); if headroom_moved && worktree_waits_for_memory( &worker_text_generation, @@ -1446,6 +1450,7 @@ impl CodeIndexSchedulerRegistryV1 { .is_some_and(LatestCodeTextGenerationV1::query_owners_are_ready); // Admission is held: queue wait ends and service time begins. let started_micros = now_micros().0; + let started_pressure = worker_resident_memory.pressure().state(); // A sealed successor answers the arrival it claimed; a later // arrival stays pending for its own pass. let (arrival, trigger) = match sealed_successor.as_ref() { @@ -3365,6 +3370,12 @@ impl CodeIndexSchedulerRegistryV1 { ); let waited_for_memory = worker_residency.refresh_waits_for_memory(); worker_residency.set_refresh_waits_for_memory(refused_for_memory); + if refused_for_memory { + worker_refused_reconcile_start + .refused(started_pressure, &worker_resident_memory); + } else { + worker_refused_reconcile_start.clear(); + } if let Ok((Err(error), _, _)) = &result && refused_for_memory { @@ -3924,6 +3935,8 @@ impl CodeIndexSchedulerRegistryV1 { .park_headroom .released_by_others(&mut owner_headroom); let mut admission_headroom = self.resident_memory.pressure().subscribe_headroom(); + let headroom_resident_memory = Arc::downgrade(&self.resident_memory); + let headroom_refused_reconcile_start = Arc::downgrade(&refused_reconcile_start); let headroom_pending_wake = Arc::downgrade(&pending_wake); let headroom_wake = Arc::downgrade(&wake); let headroom_text = Arc::downgrade(&text_generation); @@ -3967,6 +3980,12 @@ impl CodeIndexSchedulerRegistryV1 { continue; } owner_headroom_seen = by_others; + } else if let (Some(resident_memory), Some(refused_start)) = ( + headroom_resident_memory.upgrade(), + headroom_refused_reconcile_start.upgrade(), + ) && !refused_start.admits_retry(&resident_memory) + { + continue; } if worktree_waits_for_memory(&text, &park, &residency) { Self::note_wake( From d506a3413871e8dc801911105ca3371540539a8b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:03:55 -0700 Subject: [PATCH 3/8] test(privacy): expect high-entropy object keys redacted in place JSON and TOML documents whose keys read as high-entropy tokens are withheld whole today. Pin the target behavior: the key becomes a placeholder unique within its object, the values under it are redacted, and passphrase-shaped secrets stay redacted in every position. Co-Authored-By: Claude Opus 5.5 --- .../src/structured_text_tests.rs | 1 + .../structured_text_tests/key_redaction.rs | 246 ++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs diff --git a/crates/tracedecay-privacy/src/structured_text_tests.rs b/crates/tracedecay-privacy/src/structured_text_tests.rs index f33337183d..e588230a9f 100644 --- a/crates/tracedecay-privacy/src/structured_text_tests.rs +++ b/crates/tracedecay-privacy/src/structured_text_tests.rs @@ -26,6 +26,7 @@ use super::structured_text::{ }; mod code_shape; +mod key_redaction; /// Ordinary words: no credential prefix, no digits, shorter than the entropy /// floor, and containing no substring the credential patterns know. diff --git a/crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs b/crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs new file mode 100644 index 0000000000..df3242dbeb --- /dev/null +++ b/crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs @@ -0,0 +1,246 @@ +//! Object keys that trip a detector are rewritten in place, never exempted. +//! +//! Detection stays exactly as strict for keys as for values and prose: a key +//! that reads as a high-entropy token or a credential is replaced by a +//! placeholder unique within its object, and every scalar under it is +//! redacted. The document keeps its format, so the rest of it stays indexable. + +use serde_json::Value; +use tracedecay_domain::SanitizerDispositionV1; + +use super::{ + CodeSourceShapeV1, DetectionError, StructuredTextFormatV1, sanitize_code_source_bytes, + sanitize_lcm_payload_text, sanitize_structured_text, +}; + +/// Passphrase-shaped secrets that read like identifiers. No shape rule can +/// tell them apart from long type or field names, so they must stay redacted +/// in every position. +const PASSPHRASES: [&str; 3] = [ + "violin-pocket-jungle-quartz-island-778", + "RocketButtonStapleBridgeYellowOrange90", + "MySuperSecretPasswordForProduction2024Admin", +]; + +fn sanitize_data(raw: &str) -> String { + let sanitized = sanitize_code_source_bytes(raw.as_bytes(), CodeSourceShapeV1::StructuredData) + .unwrap_or_else(|error| panic!("structured data sanitizes in place ({error}): {raw}")); + assert_eq!( + sanitized.receipt().disposition(), + SanitizerDispositionV1::Redacted + ); + String::from_utf8(sanitized.into_parts().0).expect("sanitized text is UTF-8") +} + +fn assert_fixed_point(sanitized: &str) { + let replayed = sanitize_structured_text(sanitized).expect("replay scan runs"); + assert_eq!( + replayed.sanitized_text(), + sanitized, + "sanitizing the sanitized payload must be a fixed point" + ); +} + +#[test] +fn json_secret_keys_become_placeholders_and_their_values_are_redacted() { + for secret in PASSPHRASES { + let raw = format!(r#"{{"api_keys": {{"{secret}": "admin-user"}}, "region": "us-east"}}"#); + + let text = sanitize_data(&raw); + + assert!(!text.contains(secret), "secret key survived: {text}"); + assert!( + !text.contains("admin-user"), + "value under it survived: {text}" + ); + let parsed: Value = serde_json::from_str(&text).expect("still valid JSON"); + assert_eq!(parsed["region"], "us-east"); + assert_eq!( + parsed["api_keys"]["TraceDecay-redacted-key-1"], + "TraceDecay-redacted-sensitive-field" + ); + assert_fixed_point(&text); + } +} + +#[test] +fn placeholders_are_unique_within_their_object() { + let [first, second, third] = PASSPHRASES; + let raw = format!( + "{{\n \"TraceDecay-redacted-key-1\": \"kept\",\n \"{first}\": 1,\n \ + \"{second}\": {{\"nested\": \"x\"}},\n \"inner\": {{\"{third}\": true}}\n}}\n" + ); + + let text = sanitize_data(&raw); + + for secret in PASSPHRASES { + assert!(!text.contains(secret), "secret key survived: {text}"); + } + let parsed: Value = serde_json::from_str(&text).expect("still valid JSON"); + let keys: Vec<&str> = parsed + .as_object() + .expect("object") + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + [ + "TraceDecay-redacted-key-1", + "TraceDecay-redacted-key-2", + "TraceDecay-redacted-key-3", + "inner" + ], + "{text}" + ); + assert_eq!(parsed["TraceDecay-redacted-key-1"], "kept"); + assert_eq!( + parsed["TraceDecay-redacted-key-3"]["nested"], + "TraceDecay-redacted-sensitive-field" + ); + assert_eq!(parsed["inner"]["TraceDecay-redacted-key-1"], true); + assert_fixed_point(&text); +} + +#[test] +fn schema_definitions_with_long_type_names_stay_indexable() { + let raw = r##"{ + "$defs": { + "WorkDuplicateAdjudicationAppendOutcomeV1": {"type": "string", "enum": ["appended"]}, + "ProjectId": {"description": "Canonical project identity.", "type": "string"} + }, + "properties": {"outcome": {"$ref": "#/$defs/ProjectId"}} +} +"##; + + let text = sanitize_data(raw); + + assert!( + !text.contains("WorkDuplicateAdjudicationAppendOutcomeV1"), + "{text}" + ); + assert!( + text.contains( + r#""ProjectId": {"description": "Canonical project identity.", "type": "string"}"# + ), + "ordinary definitions are untouched: {text}" + ); + assert!(text.contains(r##""$ref": "#/$defs/ProjectId""##), "{text}"); + assert!( + text.contains(r#""TraceDecay-redacted-key-1": {"type""#), + "{text}" + ); + serde_json::from_str::(&text).expect("still valid JSON"); +} + +#[test] +fn benchmark_receipt_with_a_long_target_name_stays_indexable() { + let raw = r#"{"target_results": {"clone_peak_rss_added_at_most_1_gib_for_39582_symbols": true, "cold_index_completed": true}}"#; + + let text = sanitize_data(raw); + + assert_eq!( + text, + r#"{"target_results": {"TraceDecay-redacted-key-1": true, "cold_index_completed": true}}"# + ); +} + +#[test] +fn escaped_json_keys_are_replaced_byte_exactly() { + let raw = r#"{"Violin-pocket-jungle-quartz-island-778": "x", "ok": "y"}"#; + + let text = sanitize_data(raw); + + assert_eq!( + text, + r#"{"TraceDecay-redacted-key-1": "TraceDecay-redacted-sensitive-field", "ok": "y"}"# + ); +} + +#[test] +fn toml_secret_keys_become_bare_placeholders() { + let [first, second, _] = PASSPHRASES; + let raw = format!( + "[users]\n{first} = \"admin-user\"\n\"{second}\" = {{ role = \"ops\" }}\nregion = \"us-east\"\n" + ); + + let scanned = sanitize_structured_text(&raw).expect("structured scan runs"); + assert_eq!(scanned.format(), Some(StructuredTextFormatV1::Toml)); + assert!(scanned.quarantine_findings().is_empty()); + let text = scanned.sanitized_text(); + + assert_eq!( + text, + "[users]\nTraceDecay-redacted-key-1 = \"TraceDecay-redacted-sensitive-field\"\n\ + TraceDecay-redacted-key-2 = { role = \"TraceDecay-redacted-sensitive-field\" }\n\ + region = \"us-east\"\n" + ); + assert_fixed_point(text); +} + +#[test] +fn credential_keys_redact_in_place_on_the_lcm_text_route() { + let credential_key = ["sk", "-test-", "1234567890abcdef"].concat(); + let raw = format!("{credential_key} = \"ordinary-value\"\nregion = \"us-east\"\n"); + + let sanitized = sanitize_lcm_payload_text(&raw).expect("key redacts in place"); + + assert_eq!( + sanitized.sanitized_text(), + "TraceDecay-redacted-key-1 = \"TraceDecay-redacted-sensitive-field\"\nregion = \"us-east\"\n" + ); +} + +/// YAML is parsed without key spans, so a key cannot be located byte-exactly +/// and the document stays withheld. +#[test] +fn yaml_secret_keys_still_quarantine() { + for secret in PASSPHRASES { + let raw = format!("users:\n {secret}: admin-user\nregion: us-east\n"); + + let error = sanitize_code_source_bytes(raw.as_bytes(), CodeSourceShapeV1::StructuredData) + .map(|_| ()) + .expect_err("unlocatable key quarantines"); + + assert_eq!(error, DetectionError::CredentialKeyQuarantine); + } +} + +/// A TOML table named twice has one parsed key but two occurrences. The +/// second is only raw-scanned, which breaks the header, so the redacted +/// document no longer parses and the file stays withheld. +#[test] +fn toml_secret_keys_that_cannot_be_rewritten_validly_still_quarantine() { + let secret = PASSPHRASES[0]; + let raw = format!("[{secret}]\nname = \"a\"\n\n[{secret}.child]\nname = \"b\"\n"); + + let error = sanitize_code_source_bytes(raw.as_bytes(), CodeSourceShapeV1::StructuredData) + .map(|_| ()) + .expect_err("unrewritable key quarantines"); + + assert_eq!(error, DetectionError::CredentialKeyQuarantine); +} + +#[test] +fn passphrases_in_values_and_free_text_stay_redacted() { + for secret in PASSPHRASES { + for raw in [ + format!("{{\"note\": \"{secret}\"}}"), + format!("{{\"url\": \"https://example.test/hooks/{secret}/run\"}}"), + ] { + let text = sanitize_data(&raw); + assert!(!text.contains(secret), "{text}"); + } + for raw in [ + format!("let label = \"{secret}\";\n"), + format!("Remember to use {secret} when you type the password.\n"), + format!("type Alias = {secret};\n"), + ] { + let sanitized = + sanitize_code_source_bytes(raw.as_bytes(), CodeSourceShapeV1::CodeOrProse) + .expect("prose sanitizes"); + let text = String::from_utf8(sanitized.into_parts().0).expect("UTF-8"); + assert!(!text.contains(secret), "{text}"); + } + } +} From 60c4b854699060f6fa2dac77aacc2dcfa1dcdec1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:50:41 -0700 Subject: [PATCH 4/8] fix(privacy): redact flagged object keys in place A JSON or TOML object key that trips a credential or entropy detector withheld the whole document, because the sanitizer had no way to rewrite a key. Long identifiers used as keys (schema $defs type names, benchmark target names, path keys) score above the entropy floor, so files such as dashboard-contracts.schema.json were never indexed. The span-reporting JSON and TOML parsers already give each key's byte range. Replace a flagged key with a placeholder unique within its object (TraceDecay-redacted-key-N), redact every scalar under it, and keep the rest of the document byte-exact. Detection is unchanged: no token is exempted. A document is still withheld when the key has no span (YAML) or the rewritten text no longer parses as its format. Co-Authored-By: Claude Opus 5.5 --- crates/tracedecay-privacy/src/detect.rs | 6 +- .../tracedecay-privacy/src/structured_text.rs | 363 +++++++++++------- .../src/structured_text_tests.rs | 34 -- .../structured_text_tests/key_redaction.rs | 10 +- 4 files changed, 238 insertions(+), 175 deletions(-) diff --git a/crates/tracedecay-privacy/src/detect.rs b/crates/tracedecay-privacy/src/detect.rs index 18ea9068c6..d5b8514a6e 100644 --- a/crates/tracedecay-privacy/src/detect.rs +++ b/crates/tracedecay-privacy/src/detect.rs @@ -409,9 +409,9 @@ pub enum DetectionError { #[error("privacy sanitizer quarantined an ambiguous structured document")] StructuredQuarantine, /// The document parsed, but key-anchored material cannot be redacted in - /// place: an object key carries credential material, or a key-proven - /// sensitive field cannot be located byte-exactly. Fail-closed quarantine, - /// not a receipt fault. + /// place: an object key carries credential material and the parser gave + /// no span to rewrite it at (YAML, the JSON value route), or the rewritten + /// document no longer parses. Fail-closed quarantine, not a receipt fault. #[error("privacy sanitizer quarantined credential-bearing keys")] CredentialKeyQuarantine, /// A parsed sensitive field could not be mapped back to one exact byte diff --git a/crates/tracedecay-privacy/src/structured_text.rs b/crates/tracedecay-privacy/src/structured_text.rs index 6d6230c3a4..9f61dcd122 100644 --- a/crates/tracedecay-privacy/src/structured_text.rs +++ b/crates/tracedecay-privacy/src/structured_text.rs @@ -198,12 +198,15 @@ fn sanitize_structured_text_with( .map_err(detection_error_from_structured_sanitization)?; let mut quarantine_findings = Vec::new(); - let candidates = if parsed.fields.is_empty() { + let (candidates, keys) = if parsed.fields.is_empty() { tree_candidates(raw, &parsed, &policy, patterns, &mut quarantine_findings) } else { - line_candidates(raw, &parsed.fields, &policy, patterns) + ( + line_candidates(raw, &parsed.fields, &policy, patterns), + RedactedKeys::default(), + ) }; - if candidates.is_empty() && quarantine_findings.is_empty() { + if candidates.is_empty() && keys.redactions.is_empty() && quarantine_findings.is_empty() { let mut scanned = raw_only(raw, patterns); scanned.format = Some(parsed.format); return Ok(scanned); @@ -212,7 +215,7 @@ fn sanitize_structured_text_with( let ranks = ordinal_ranks(&candidates)?; let candidate_count = u32::try_from(candidates.len()).map_err(|_| DetectionError::ScanLimitExceeded)?; - let mut redactions: Vec<(Range, &'static str)> = candidates + let mut redactions: Vec<(Range, &str)> = candidates .iter() .flat_map(|candidate| { candidate @@ -220,6 +223,11 @@ fn sanitize_structured_text_with( .iter() .map(|span| (span.clone(), candidate.marker)) }) + .chain( + keys.redactions + .iter() + .map(|key| (key.span.clone(), key.placeholder.as_str())), + ) .collect(); redactions.sort_by(|(left, _), (right, _)| { left.start @@ -228,7 +236,7 @@ fn sanitize_structured_text_with( }); redactions.dedup_by(|(later, _), (earlier, _)| later.start < earlier.end); - let mut findings = Vec::new(); + let mut findings = keys.findings; let mut sanitized_text = String::with_capacity(raw.len()); let mut cursor = 0usize; for (span, marker) in redactions { @@ -281,19 +289,36 @@ fn sanitize_structured_text_with( ); } - findings.sort(); - findings.dedup(); - quarantine_findings.sort(); - quarantine_findings.dedup(); // Text-located spans (YAML only) can cover more than the value, so that // redaction keeps its format label only if it still parses as the input's // format. Parser spans replace exactly the value token. - let format = (!candidates.iter().any(|candidate| candidate.text_located) - || matches!( + let text_located = candidates.iter().any(|candidate| candidate.text_located); + let reparses = || { + matches!( parse_structured_text(&sanitized_text), Ok(Some(ref reparsed)) if reparsed.format == parsed.format - )) - .then_some(parsed.format); + ) + }; + let reparsed = (text_located || !keys.redactions.is_empty()).then(reparses); + // A rewritten key leaves the document valid unless the key also occurs + // where the parser reported no span (a TOML table named in two headers), + // whose raw-scan redaction breaks the syntax. Such a document is withheld. + if !keys.redactions.is_empty() && reparsed == Some(false) { + for key in &keys.redactions { + redact_text( + &mut key.key.clone(), + "$", + patterns, + &mut quarantine_findings, + SanitizationActionV1::Quarantined, + ); + } + } + let format = reparsed.unwrap_or(true).then_some(parsed.format); + findings.sort(); + findings.dedup(); + quarantine_findings.sort(); + quarantine_findings.dedup(); Ok(StructuredTextSanitizationV1 { format, sanitized_text, @@ -423,19 +448,20 @@ fn tree_candidates( policy: &ConfiguredSensitiveKeyPolicy<'_>, patterns: &CredentialPatternSet, quarantine_findings: &mut Vec, -) -> Vec { +) -> (Vec, RedactedKeys) { let Some(document) = document_node(raw, parsed) else { quarantine_findings.push(unlocatable_sensitive_field()); - return Vec::new(); + return (Vec::new(), RedactedKeys::default()); }; - let mut sensitive = Vec::new(); - collect_tree_fields( - document, + let mut collector = TreeFieldCollector { policy, patterns, - &mut sensitive, + fields: TreeFields::default(), quarantine_findings, - ); + }; + collector.document(document); + let TreeFields { sensitive, keys } = collector.fields; + let quarantine_findings = collector.quarantine_findings; let mut candidates = Vec::new(); for SensitiveScalar { @@ -478,7 +504,7 @@ fn tree_candidates( text_located, }); } - candidates + (candidates, keys) } fn unlocatable_sensitive_field() -> SanitizationFindingV1 { @@ -493,13 +519,22 @@ fn unlocatable_sensitive_field() -> SanitizationFindingV1 { /// One node of a parsed tree document. enum DocumentNode<'a> { - Object(Vec<(Cow<'a, str>, DocumentNode<'a>)>), + Object(Vec<(DocumentKey<'a>, DocumentNode<'a>)>), Array(Vec>), Scalar(ScalarValue), /// A boolean, null, or empty string: nothing to redact. Empty, } +/// An object key and, when the format's parser reports it, the byte range a +/// bare placeholder can replace while the document stays valid. +struct DocumentKey<'a> { + name: Cow<'a, str>, + /// `None` for formats parsed without spans (YAML): such a key cannot be + /// rewritten in place. + span: Option>, +} + /// Where a scalar's value sits in the raw text. enum ScalarValue { /// The byte range the format's own parser read, and the marker that keeps @@ -560,9 +595,17 @@ fn json_node(value: Json<'_>) -> DocumentNode<'_> { .properties .into_iter() .map(|property| { + // A string key's range includes both quotes; the + // placeholder replaces the body only. let key = match property.name { - ObjectPropName::String(name) => name.value, - ObjectPropName::Word(name) => Cow::Borrowed(name.value), + ObjectPropName::String(name) => DocumentKey { + span: Some(name.range.start + 1..name.range.end.saturating_sub(1)), + name: name.value, + }, + ObjectPropName::Word(name) => DocumentKey { + span: Some(name.range.start..name.range.end), + name: Cow::Borrowed(name.value), + }, }; (key, json_node(property.value)) }) @@ -597,7 +640,15 @@ fn toml_table_node<'a>(raw: &str, table: DeTable<'a>) -> DocumentNode<'a> { DocumentNode::Object( table .into_iter() - .map(|(key, value)| (key.into_inner(), toml_node(raw, value))) + .map(|(key, value)| { + // The span covers the whole key token, quotes included, and + // the placeholder is a valid bare key. + let key = DocumentKey { + span: Some(key.span()), + name: key.into_inner(), + }; + (key, toml_node(raw, value)) + }) .collect(), ) } @@ -642,7 +693,13 @@ fn decoded_node(value: &Value) -> DocumentNode<'_> { Value::Object(fields) => DocumentNode::Object( fields .iter() - .map(|(key, child)| (Cow::Borrowed(key.as_str()), decoded_node(child))) + .map(|(key, child)| { + let key = DocumentKey { + name: Cow::Borrowed(key.as_str()), + span: None, + }; + (key, decoded_node(child)) + }) .collect(), ), Value::Array(items) => DocumentNode::Array(items.iter().map(decoded_node).collect()), @@ -662,130 +719,172 @@ struct SensitiveScalar { decoded_value_matched: bool, } -fn collect_tree_fields( - node: DocumentNode<'_>, - policy: &ConfiguredSensitiveKeyPolicy<'_>, - patterns: &CredentialPatternSet, - sensitive: &mut Vec, - quarantine_findings: &mut Vec, -) { - match node { - DocumentNode::Object(fields) => { - for (key, child) in fields { - let mut key_evidence = key.to_string(); - redact_text( - &mut key_evidence, - "$", - patterns, - quarantine_findings, - SanitizationActionV1::Quarantined, - ); - match policy.classify(&NormalizedSensitiveKey::new(&key)) { - Some(origin) => collect_scalars(child, &key, origin, patterns, sensitive), - None => { - collect_detected_scalars( - child, - &key, - policy, - patterns, - sensitive, - quarantine_findings, - ); - } +/// An object key the detectors flagged, located byte-exactly in the raw text. +struct KeyRedaction { + span: Range, + placeholder: String, + /// The decoded key, kept only so a rewrite that breaks the document can be + /// reported as the key quarantine it falls back to. + key: String, +} + +/// Prefix of the placeholder that replaces a flagged object key. Like +/// [`REDACTED_STRUCTURED_FIELD`] it is a valid bare TOML key and needs no JSON +/// escaping, so the rewritten document still parses as its own format. +const REDACTED_KEY_PREFIX: &str = "TraceDecay-redacted-key-"; + +/// The flagged keys of one document and the detector findings that flagged +/// them. +#[derive(Default)] +struct RedactedKeys { + redactions: Vec, + findings: Vec, +} + +/// Everything a walk over a parsed tree document collects. +#[derive(Default)] +struct TreeFields { + sensitive: Vec, + keys: RedactedKeys, +} + +struct TreeFieldCollector<'a> { + policy: &'a ConfiguredSensitiveKeyPolicy<'a>, + patterns: &'a CredentialPatternSet, + fields: TreeFields, + quarantine_findings: &'a mut Vec, +} + +impl TreeFieldCollector<'_> { + fn document(&mut self, node: DocumentNode<'_>) { + match node { + DocumentNode::Object(fields) => self.object(fields), + DocumentNode::Array(items) => { + for item in items { + self.detected_scalars(item, "$"); } } + DocumentNode::Scalar(_) | DocumentNode::Empty => {} } - DocumentNode::Array(items) => { - for item in items { - collect_detected_scalars( - item, - "$", - policy, - patterns, - sensitive, - quarantine_findings, - ); - } - } - DocumentNode::Scalar(_) | DocumentNode::Empty => {} } -} -fn collect_scalars( - node: DocumentNode<'_>, - key: &str, - origin: SanitizationDetectorOriginV1, - patterns: &CredentialPatternSet, - sensitive: &mut Vec, -) { - match node { - DocumentNode::Scalar(value) => { - let decoded_value_matched = value - .decoded_value() - .is_some_and(|decoded| trips_a_detector(decoded, patterns)); - sensitive.push(SensitiveScalar { - key: key.to_owned(), - value, - origin, - decoded_value_matched, + fn object(&mut self, fields: Vec<(DocumentKey<'_>, DocumentNode<'_>)>) { + let evidence: Vec<_> = fields + .iter() + .map(|(key, _)| self.key_evidence(key)) + .collect(); + // Placeholders are numbered in document order, which a TOML table's + // sorted map does not preserve, and skip any sibling's literal name. + let mut flagged: Vec<_> = (0..fields.len()) + .filter(|&index| evidence[index].is_some()) + .collect(); + flagged.sort_by_key(|&index| fields[index].0.span.as_ref().map(|span| span.start)); + let mut placeholders = vec![None; fields.len()]; + let mut ordinal = 0usize; + for index in flagged { + placeholders[index] = Some(loop { + ordinal += 1; + let placeholder = format!("{REDACTED_KEY_PREFIX}{ordinal}"); + if !fields.iter().any(|(key, _)| key.name == placeholder) { + break placeholder; + } }); } - DocumentNode::Object(fields) => { - for (_, child) in fields { - collect_scalars(child, key, origin, patterns, sensitive); - } - } - DocumentNode::Array(items) => { - for item in items { - collect_scalars(item, key, origin, patterns, sensitive); + for (((key, child), evidence), placeholder) in + fields.into_iter().zip(evidence).zip(placeholders) + { + let origin = self + .policy + .classify(&NormalizedSensitiveKey::new(&key.name)); + match (key.span, evidence, placeholder) { + (Some(span), Some(evidence), Some(placeholder)) => { + self.fields.keys.findings.extend(evidence); + self.scalars( + child, + &placeholder, + origin.unwrap_or(SanitizationDetectorOriginV1::BuiltInDetectorKernel), + ); + self.fields.keys.redactions.push(KeyRedaction { + span, + placeholder, + key: key.name.into_owned(), + }); + } + (_, evidence, _) => { + self.quarantine_findings + .extend(evidence.into_iter().flatten()); + match origin { + Some(origin) => self.scalars(child, &key.name, origin), + None => self.detected_scalars(child, &key.name), + } + } } } - DocumentNode::Empty => {} } -} -fn collect_detected_scalars( - node: DocumentNode<'_>, - key: &str, - policy: &ConfiguredSensitiveKeyPolicy<'_>, - patterns: &CredentialPatternSet, - sensitive: &mut Vec, - quarantine_findings: &mut Vec, -) { - match node { - DocumentNode::Scalar(value) => { - if value - .decoded_value() - .is_some_and(|decoded| trips_a_detector(decoded, patterns)) - { - sensitive.push(SensitiveScalar { + /// Detector findings for a key that trips one, recorded as a redaction + /// when the key can be rewritten in place and as a quarantine otherwise. + fn key_evidence(&self, key: &DocumentKey<'_>) -> Option> { + let action = if key.span.is_some() { + SanitizationActionV1::Redacted + } else { + SanitizationActionV1::Quarantined + }; + let mut probe = key.name.to_string(); + let mut findings = Vec::new(); + redact_text(&mut probe, "$", self.patterns, &mut findings, action).then_some(findings) + } + + fn scalars(&mut self, node: DocumentNode<'_>, key: &str, origin: SanitizationDetectorOriginV1) { + match node { + DocumentNode::Scalar(value) => { + let decoded_value_matched = value + .decoded_value() + .is_some_and(|decoded| trips_a_detector(decoded, self.patterns)); + self.fields.sensitive.push(SensitiveScalar { key: key.to_owned(), value, - origin: SanitizationDetectorOriginV1::BuiltInDetectorKernel, - decoded_value_matched: true, + origin, + decoded_value_matched, }); } + DocumentNode::Object(fields) => { + for (_, child) in fields { + self.scalars(child, key, origin); + } + } + DocumentNode::Array(items) => { + for item in items { + self.scalars(item, key, origin); + } + } + DocumentNode::Empty => {} } - DocumentNode::Object(fields) => collect_tree_fields( - DocumentNode::Object(fields), - policy, - patterns, - sensitive, - quarantine_findings, - ), - DocumentNode::Array(items) => { - for item in items { - collect_detected_scalars( - item, - key, - policy, - patterns, - sensitive, - quarantine_findings, - ); + } + + fn detected_scalars(&mut self, node: DocumentNode<'_>, key: &str) { + match node { + DocumentNode::Scalar(value) => { + if value + .decoded_value() + .is_some_and(|decoded| trips_a_detector(decoded, self.patterns)) + { + self.fields.sensitive.push(SensitiveScalar { + key: key.to_owned(), + value, + origin: SanitizationDetectorOriginV1::BuiltInDetectorKernel, + decoded_value_matched: true, + }); + } + } + DocumentNode::Object(fields) => self.object(fields), + DocumentNode::Array(items) => { + for item in items { + self.detected_scalars(item, key); + } } + DocumentNode::Empty => {} } - DocumentNode::Empty => {} } } diff --git a/crates/tracedecay-privacy/src/structured_text_tests.rs b/crates/tracedecay-privacy/src/structured_text_tests.rs index e588230a9f..cbb28610fc 100644 --- a/crates/tracedecay-privacy/src/structured_text_tests.rs +++ b/crates/tracedecay-privacy/src/structured_text_tests.rs @@ -506,25 +506,6 @@ fn lcm_json_credential_bearing_keys_quarantine_instead_of_faulting_the_receipt() ); } -#[test] -fn lcm_non_json_credential_bearing_keys_are_key_quarantine_not_parse_ambiguity() { - // Same key-quarantine contract as the JSON container path, reached through - // the non-JSON structured-text route: a parsed TOML table whose *key* is - // itself credential material. (TOML rather than `key: value`, which the - // format probe reads as an HTTP header block whose line path never scans - // keys.) The refusal must name the key quarantine, not claim the document - // was ambiguous. It parsed fine. - let credential_key = ["sk", "-test-", "1234567890abcdef"].concat(); - let raw = format!("{credential_key} = \"ordinary-value\"\nregion = \"us-east\"\n"); - - let error = sanitize_lcm_payload_text(&raw).expect_err("key quarantine"); - assert_eq!(error, DetectionError::CredentialKeyQuarantine); - assert_eq!( - error.to_string(), - "privacy sanitizer quarantined credential-bearing keys" - ); -} - #[test] fn prose_opening_with_a_bracketed_annotation_line_is_not_toml_quarantine() { // LCM compaction emits scaffold messages such as @@ -595,21 +576,6 @@ fn sanitizer_verdicts_stay_distinguishable_from_sanitizer_faults() { } } -#[test] -fn code_source_credential_bearing_keys_are_key_quarantine_not_parse_ambiguity() { - let credential_key = ["sk", "-test-", "1234567890abcdef"].concat(); - let raw = format!("{credential_key} = \"ordinary-value\"\nregion = \"us-east\"\n"); - - let error = sanitize_code_source_bytes(raw.as_bytes(), CodeSourceShapeV1::StructuredData) - .map(|_| ()) - .expect_err("key quarantine"); - assert_eq!(error, DetectionError::CredentialKeyQuarantine); - assert_eq!( - error.to_string(), - "privacy sanitizer quarantined credential-bearing keys" - ); -} - #[test] fn code_source_parse_ambiguity_stays_a_structured_quarantine() { let raw = format!("vault_passphrase: {PLACEHOLDER}\n broken: [unclosed\n"); diff --git a/crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs b/crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs index df3242dbeb..898944911d 100644 --- a/crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs +++ b/crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs @@ -179,16 +179,14 @@ fn toml_secret_keys_become_bare_placeholders() { } #[test] -fn credential_keys_redact_in_place_on_the_lcm_text_route() { +fn credential_keys_redact_in_place_on_code_source_and_lcm_text_routes() { let credential_key = ["sk", "-test-", "1234567890abcdef"].concat(); let raw = format!("{credential_key} = \"ordinary-value\"\nregion = \"us-east\"\n"); + let expected = "TraceDecay-redacted-key-1 = \"TraceDecay-redacted-sensitive-field\"\nregion = \"us-east\"\n"; + assert_eq!(sanitize_data(&raw), expected); let sanitized = sanitize_lcm_payload_text(&raw).expect("key redacts in place"); - - assert_eq!( - sanitized.sanitized_text(), - "TraceDecay-redacted-key-1 = \"TraceDecay-redacted-sensitive-field\"\nregion = \"us-east\"\n" - ); + assert_eq!(sanitized.sanitized_text(), expected); } /// YAML is parsed without key spans, so a key cannot be located byte-exactly From 08f86e73e6aa4b3a27c0c676d65f0c7186849adf Mon Sep 17 00:00:00 2001 From: Zack Jackson Date: Sat, 24 Oct 2026 07:42:08 -0700 Subject: [PATCH 5/8] fix(privacy): verify descendant and repeated key redaction --- .../tracedecay-privacy/src/structured_text.rs | 84 ++++++++++++++----- .../structured_text_tests/key_redaction.rs | 75 +++++++++++++++++ 2 files changed, 138 insertions(+), 21 deletions(-) diff --git a/crates/tracedecay-privacy/src/structured_text.rs b/crates/tracedecay-privacy/src/structured_text.rs index 9f61dcd122..60cb97d8e8 100644 --- a/crates/tracedecay-privacy/src/structured_text.rs +++ b/crates/tracedecay-privacy/src/structured_text.rs @@ -293,16 +293,30 @@ fn sanitize_structured_text_with( // redaction keeps its format label only if it still parses as the input's // format. Parser spans replace exactly the value token. let text_located = candidates.iter().any(|candidate| candidate.text_located); - let reparses = || { - matches!( - parse_structured_text(&sanitized_text), - Ok(Some(ref reparsed)) if reparsed.format == parsed.format - ) - }; - let reparsed = (text_located || !keys.redactions.is_empty()).then(reparses); - // A rewritten key leaves the document valid unless the key also occurs - // where the parser reported no span (a TOML table named in two headers), - // whose raw-scan redaction breaks the syntax. Such a document is withheld. + let reparsed = (text_located || !keys.redactions.is_empty()).then(|| { + let Ok(Some(reparsed)) = parse_structured_text(&sanitized_text) else { + return false; + }; + if reparsed.format != parsed.format { + return false; + } + if !keys.redactions.is_empty() { + // TOML's semantic table merges repeated path components and can + // report only one occurrence's span. Valid syntax alone cannot + // prove every escaped occurrence was replaced. Inspect all keys + // decoded by the canonical parser before allowing durable output. + TreeFieldCollector { + policy: &policy, + patterns, + fields: TreeFields::default(), + quarantine_findings: &mut quarantine_findings, + } + .quarantine_remaining_keys(&reparsed.value); + } + true + }); + // Some unlocated occurrences instead make the raw-scan rewrite invalid. + // They retain the same typed key quarantine. if !keys.redactions.is_empty() && reparsed == Some(false) { for key in &keys.redactions { redact_text( @@ -758,7 +772,7 @@ struct TreeFieldCollector<'a> { impl TreeFieldCollector<'_> { fn document(&mut self, node: DocumentNode<'_>) { match node { - DocumentNode::Object(fields) => self.object(fields), + DocumentNode::Object(fields) => self.object(fields, None), DocumentNode::Array(items) => { for item in items { self.detected_scalars(item, "$"); @@ -768,7 +782,11 @@ impl TreeFieldCollector<'_> { } } - fn object(&mut self, fields: Vec<(DocumentKey<'_>, DocumentNode<'_>)>) { + fn object( + &mut self, + fields: Vec<(DocumentKey<'_>, DocumentNode<'_>)>, + sensitive_parent: Option<(&str, SanitizationDetectorOriginV1)>, + ) { let evidence: Vec<_> = fields .iter() .map(|(key, _)| self.key_evidence(key)) @@ -802,7 +820,9 @@ impl TreeFieldCollector<'_> { self.scalars( child, &placeholder, - origin.unwrap_or(SanitizationDetectorOriginV1::BuiltInDetectorKernel), + origin + .or(sensitive_parent.map(|(_, origin)| origin)) + .unwrap_or(SanitizationDetectorOriginV1::BuiltInDetectorKernel), ); self.fields.keys.redactions.push(KeyRedaction { span, @@ -813,8 +833,11 @@ impl TreeFieldCollector<'_> { (_, evidence, _) => { self.quarantine_findings .extend(evidence.into_iter().flatten()); - match origin { - Some(origin) => self.scalars(child, &key.name, origin), + match origin + .map(|origin| (key.name.as_ref(), origin)) + .or(sensitive_parent) + { + Some((key, origin)) => self.scalars(child, key, origin), None => self.detected_scalars(child, &key.name), } } @@ -835,6 +858,29 @@ impl TreeFieldCollector<'_> { redact_text(&mut probe, "$", self.patterns, &mut findings, action).then_some(findings) } + fn quarantine_remaining_keys(&mut self, value: &Value) { + match value { + Value::Object(fields) => { + for (name, child) in fields { + let key = DocumentKey { + name: Cow::Borrowed(name), + span: None, + }; + if let Some(evidence) = self.key_evidence(&key) { + self.quarantine_findings.extend(evidence); + } + self.quarantine_remaining_keys(child); + } + } + Value::Array(items) => { + for item in items { + self.quarantine_remaining_keys(item); + } + } + _ => {} + } + } + fn scalars(&mut self, node: DocumentNode<'_>, key: &str, origin: SanitizationDetectorOriginV1) { match node { DocumentNode::Scalar(value) => { @@ -848,11 +894,7 @@ impl TreeFieldCollector<'_> { decoded_value_matched, }); } - DocumentNode::Object(fields) => { - for (_, child) in fields { - self.scalars(child, key, origin); - } - } + DocumentNode::Object(fields) => self.object(fields, Some((key, origin))), DocumentNode::Array(items) => { for item in items { self.scalars(item, key, origin); @@ -877,7 +919,7 @@ impl TreeFieldCollector<'_> { }); } } - DocumentNode::Object(fields) => self.object(fields), + DocumentNode::Object(fields) => self.object(fields, None), DocumentNode::Array(items) => { for item in items { self.detected_scalars(item, key); diff --git a/crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs b/crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs index 898944911d..f588779517 100644 --- a/crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs +++ b/crates/tracedecay-privacy/src/structured_text_tests/key_redaction.rs @@ -34,6 +34,7 @@ fn sanitize_data(raw: &str) -> String { fn assert_fixed_point(sanitized: &str) { let replayed = sanitize_structured_text(sanitized).expect("replay scan runs"); + assert!(replayed.quarantine_findings().is_empty()); assert_eq!( replayed.sanitized_text(), sanitized, @@ -157,6 +158,80 @@ fn escaped_json_keys_are_replaced_byte_exactly() { ); } +#[test] +fn nested_escaped_json_keys_are_redacted_under_a_flagged_parent() { + let secret = PASSPHRASES[0]; + let raw = format!( + r#"{{"{secret}":{{"violin-pocket-ju\u006egle-quartz-island-778":true,"ordinary":"private-value"}}}}"# + ); + let original: Value = serde_json::from_str(&raw).expect("real JSON with an escaped nested key"); + assert_eq!(original[secret][secret], true); + + let text = sanitize_data(&raw); + let parsed: Value = serde_json::from_str(&text).expect("redaction keeps valid JSON"); + assert!( + !serde_json::to_string(&parsed).unwrap().contains(secret), + "{text}" + ); + assert!( + !text.contains("private-value"), + "forced value redaction survives: {text}" + ); + assert_fixed_point(&text); +} + +fn assert_repeated_toml_keys_are_redacted_or_withheld(raw: &str) { + let original: toml::Value = toml::from_str(raw).expect("valid repeated TOML path components"); + assert!(original.as_table().unwrap().contains_key(PASSPHRASES[0])); + let sanitized = + match sanitize_code_source_bytes(raw.as_bytes(), CodeSourceShapeV1::StructuredData) { + Ok(sanitized) => sanitized, + Err(DetectionError::CredentialKeyQuarantine) => { + return; + } + Err(error) => panic!("expected exact redaction or typed key quarantine: {error}"), + }; + assert_eq!( + sanitized.receipt().disposition(), + SanitizerDispositionV1::Redacted + ); + let text = String::from_utf8(sanitized.into_parts().0).unwrap(); + let parsed: toml::Value = toml::from_str(&text).expect("redacted TOML remains valid"); + assert!( + !toml::to_string(&parsed).unwrap().contains(PASSPHRASES[0]), + "{text}" + ); + assert_fixed_point(&text); +} + +#[test] +fn repeated_escaped_toml_dotted_keys_do_not_leave_decoded_secrets() { + let raw = concat!( + "\"violin-pocket-ju\\u006egle-quartz-island-778\".a=true\n", + "\"violin-pocket-ju\\u006egle-quartz-island-778\".b=true\n", + ); + assert_repeated_toml_keys_are_redacted_or_withheld(raw); + assert_eq!( + sanitize_lcm_payload_text(raw).map(|_| ()), + Err(DetectionError::CredentialKeyQuarantine) + ); +} + +#[test] +fn repeated_escaped_toml_quoted_headers_do_not_leave_decoded_secrets() { + let raw = concat!( + "[\"violin-pocket-ju\\u006egle-quartz-island-778\".a]\nx=true\n", + "[\"violin-pocket-ju\\u006egle-quartz-island-778\".b]\nx=true\n", + ); + assert_repeated_toml_keys_are_redacted_or_withheld(raw); + // LCM's existing JSON-intent guard withholds a leading `["` before the + // structured-source route gets to identify the quoted TOML header. + assert_eq!( + sanitize_lcm_payload_text(raw).map(|_| ()), + Err(DetectionError::StructuredQuarantine) + ); +} + #[test] fn toml_secret_keys_become_bare_placeholders() { let [first, second, _] = PASSPHRASES; From df4ae5c3392802d88d27c0fda92df87130f7db76 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:30:37 -0700 Subject: [PATCH 6/8] fix(source-edit): insert moved imports at Rust syntax boundaries (#4516) --- .../tracedecay-source-edit/src/edits/mod.rs | 2 + .../src/edits/move_destination_tests.rs | 268 ++++++++++++++++++ .../src/edits/preview.rs | 11 +- .../src/move_symbol/mod.rs | 3 +- .../src/move_symbol/rendering.rs | 213 +++++++++++--- 5 files changed, 449 insertions(+), 48 deletions(-) create mode 100644 crates/tracedecay-source-edit/src/edits/move_destination_tests.rs 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( From 3e10b5fbb0a5d49b59c7fbe7216e43c6f10b0a2d Mon Sep 17 00:00:00 2001 From: Zack Jackson Date: Sat, 24 Oct 2026 18:24:34 -0700 Subject: [PATCH 7/8] fix(code-index): bound cold construction and retain refused publication --- .../src/code_index_generations.rs | 57 +- .../src/code_index_generations/tests.rs | 209 +++ .../code_index_scheduler/git_tree_capture.rs | 2 + .../code_index_scheduler/graph_activation.rs | 2 +- .../ignored_dependencies.rs | 26 +- .../src/code_index_scheduler/memory_tests.rs | 386 +++-- .../code_index_scheduler/publication_store.rs | 1320 +++++++++------- .../src/code_index_scheduler/reconcile.rs | 299 ++-- .../src/code_index_scheduler/registry.rs | 323 +++- .../registry/ignored_dependencies.rs | 14 + .../code_index_scheduler/registry/mount.rs | 161 +- .../src/code_index_scheduler/serving.rs | 6 + .../tests/publication_store.rs | 672 +++++++- .../code_index_scheduler/tests/residency.rs | 686 ++++++++- .../tests/code_index_scheduler/reconcile.rs | 475 ++++-- .../tests/code_index_scheduler/serving.rs | 18 +- .../benches/code_index_chunks.rs | 7 +- .../benches/partitioned_codec.rs | 18 +- .../benches/sealed_storage.rs | 219 ++- .../src/production/file_evidence_rows.rs | 333 +++- .../src/production/file_evidence_tests.rs | 82 +- .../src/production/generation_statistics.rs | 2 +- .../src/production/go_satisfaction.rs | 72 +- .../src/production/graph_pages.rs | 416 +++-- .../src/production/helpers.rs | 1345 ++++++++++------- .../src/production/ignored_sources.rs | 6 + .../production/lexical_page_source_tests.rs | 18 +- .../src/production/memory_store.rs | 395 ++++- .../src/production/mod.rs | 1274 ++++++++++++---- .../src/production/module_resolution.rs | 488 ++++-- .../src/production/partitioned_codec.rs | 574 +++++-- .../src/production/projection_rows.rs | 409 +++-- .../src/production/resident_bytes.rs | 686 ++++++++- .../src/production/resolution_index.rs | 339 +++-- .../src/production/resolution_outputs.rs | 158 +- .../src/production/resolution_view.rs | 1115 +++++++++++++- .../src/production/sealed_codec.rs | 4 +- .../src/production/sealed_parent.rs | 273 +++- .../production/sparse_differential_tests.rs | 22 +- .../src/production/sparse_increment.rs | 382 +++-- .../src/production/sparse_increment_tests.rs | 326 +++- .../src/production/sparse_resolution.rs | 764 ++++++++-- .../src/production/sparse_successor.rs | 639 +++++++- .../src/production/typescript_resolution.rs | 307 ++-- .../src/production/worker_tests.rs | 904 ++++++++++- .../tracedecay-code-index/src/projection.rs | 239 ++- .../src/projection/tests.rs | 236 ++- crates/tracedecay-code-index/src/receipts.rs | 576 +++---- .../cross_file_import_calls.rs | 1 + .../production_orchestration.rs | 359 +++-- .../parallel_equivalence.rs | 6 + .../code_index_suite/relation_coverage.rs | 1 + .../tests/code_index_suite/support.rs | 14 +- .../typescript_module_resolution.rs | 15 +- .../tests/resident_accounting.rs | 132 +- .../src/code_intelligence/search.rs | 83 +- crates/tracedecay-domain/src/lib.rs | 3 +- .../src/research/canonical_serializer.rs | 40 + .../src/research/canonical_tests.rs | 58 + .../benches/code_lexical_catchup.rs | 59 +- crates/tracedecay-query/src/bench_support.rs | 27 +- .../src/bin/tracedecay_index_bench.rs | 7 + .../src/bin/tracedecay_search_bench.rs | 7 + .../candidate_producers.rs | 27 +- crates/tracedecay-runtime-core/src/logging.rs | 31 +- .../src/resident_memory.rs | 463 +++++- .../src/resident_memory/tests.rs | 766 ++++++++++ .../src/candidate_output.rs | 22 +- .../src/candidate_output/cancellation.rs | 6 + 69 files changed, 15075 insertions(+), 4336 deletions(-) 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..8dc2fb2865 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,47 @@ 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 { + let error = prefer_resident_memory_refusal(control, error.into()); + 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 b016264218..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,7 +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::ResidentMemoryPressureStateV1; +use tracedecay_runtime_core::resident_memory::{ + ResidentMemoryReconstructionDemandFailureV1, ResidentMemoryReconstructionDemandV1, +}; #[cfg(test)] mod cold_read_wake_tests; @@ -1671,98 +1673,275 @@ 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 waiting(&self) -> bool { - self.waiting.load(Ordering::Acquire) + fn finish_reconstruction(&self) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .reconstruction = None; } -} -/// The measured process size a reconcile refused for resident memory started -/// from: it needed more headroom than that left it. -/// -/// Its own release returns the process to about where it started, and the -/// watermark latch clearing behind that release advances the admission -/// headroom epoch. Retrying on that epoch rebuilt the same refused pass -/// forever (issue #4505), so admission headroom retries the pass only once -/// the process measures below this. The bound registers with the authority, -/// so a sample under it advances the epoch even with the latch already clear. -struct RefusedReconcileStartV1(AtomicU64); - -impl Default for RefusedReconcileStartV1 { - fn default() -> Self { - Self(AtomicU64::new(u64::MAX)) + fn begin_retry(&self) { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .waiting = false; } -} -impl RefusedReconcileStartV1 { - /// A pass that began under `started` was refused for memory. Only a pass - /// that itself carried the process over the watermark is bounded: a - /// ledger refusal, or one admitted while already over, is lifted by the - /// release or the latch clearing it waits on. - fn refused( + fn waiting(&self) -> bool { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .waiting + } + + fn record_memory_refusal( &self, - started: ResidentMemoryPressureStateV1, - resident_memory: &Arc, - ) { - if started.is_over_budget() || !resident_memory.pressure().state().is_over_budget() { - return; + 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) + } } - if let Some(started_bytes) = started.observed_bytes() { - self.0.fetch_min(started_bytes, Ordering::AcqRel); - self.register(resident_memory); + } + + 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 clear(&self) { - self.0.store(u64::MAX, Ordering::Release); + fn bytes(value: u64) -> NonZeroU64 { + NonZeroU64::new(value).unwrap() } - /// Whether the process now measures below where the refused pass - /// started. Waiters settle together, so an epoch another waiter settled - /// re-registers this bound before it reads as no. - fn admits_retry( - &self, - resident_memory: &Arc, - ) -> bool { - let started_bytes = self.0.load(Ordering::Acquire); - if started_bytes == u64::MAX { - return true; - } - let admits = resident_memory - .pressure() - .state() - .observed_bytes() - .is_none_or(|observed| observed < started_bytes); - if !admits { - self.register(resident_memory); - } - admits + #[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()); } - fn register(&self, resident_memory: &Arc) { - let started_bytes = self.0.load(Ordering::Acquire); - if started_bytes == u64::MAX { - return; - } - let ceiling = resident_memory.pressure().limit_bytes(); - resident_memory.wait_for_headroom( - ceiling.saturating_sub(started_bytes.saturating_sub(1)), - ceiling, + #[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 28b6e405fd..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 @@ -729,8 +729,6 @@ impl CodeIndexSchedulerRegistryV1 { let worker_pending_wake = Arc::clone(&pending_wake); let memory_retry = Arc::new(super::MemoryRefusalRetryV1::default()); let worker_memory_retry = Arc::clone(&memory_retry); - let refused_reconcile_start = Arc::new(super::RefusedReconcileStartV1::default()); - let worker_refused_reconcile_start = Arc::clone(&refused_reconcile_start); let worker_cadence_telemetry = Arc::clone(&self.cadence_telemetry); let worker_shutting_down = Arc::clone(&shutting_down); let worker_build_publication_lock = Arc::clone(&build_publication_lock); @@ -806,7 +804,6 @@ impl CodeIndexSchedulerRegistryV1 { .park_headroom .released_by_others(&mut worker_owner_headroom); let mut worker_admission_headroom = self.resident_memory.pressure().subscribe_headroom(); - let worker_resident_memory = Arc::clone(&self.resident_memory); // Boxed at definition on purpose: this worker's state machine is the // largest future in the daemon (reconcile + text advance + decode + // activation + swap inline), and an unboxed `let` materializes the @@ -912,9 +909,9 @@ impl CodeIndexSchedulerRegistryV1 { .park_headroom .released_by_others(&mut worker_owner_headroom) > worker_owner_headroom_seen - || (worker_admission_headroom.has_changed() - && worker_refused_reconcile_start.admits_retry(&worker_resident_memory)); + || 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, @@ -1033,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. @@ -1450,7 +1450,6 @@ impl CodeIndexSchedulerRegistryV1 { .is_some_and(LatestCodeTextGenerationV1::query_owners_are_ready); // Admission is held: queue wait ends and service time begins. let started_micros = now_micros().0; - let started_pressure = worker_resident_memory.pressure().state(); // A sealed successor answers the arrival it claimed; a later // arrival stays pending for its own pass. let (arrival, trigger) = match sealed_successor.as_ref() { @@ -1478,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. @@ -1653,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", @@ -2409,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, @@ -2717,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; @@ -2832,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()) @@ -2875,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, @@ -2946,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 => { @@ -2988,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( @@ -3045,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, @@ -3056,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, @@ -3364,21 +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 refused_for_memory { - worker_refused_reconcile_start - .refused(started_pressure, &worker_resident_memory); - } else { - worker_refused_reconcile_start.clear(); - } - 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(), @@ -3697,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 { @@ -3935,13 +4012,13 @@ impl CodeIndexSchedulerRegistryV1 { .park_headroom .released_by_others(&mut owner_headroom); let mut admission_headroom = self.resident_memory.pressure().subscribe_headroom(); - let headroom_resident_memory = Arc::downgrade(&self.resident_memory); - let headroom_refused_reconcile_start = Arc::downgrade(&refused_reconcile_start); let headroom_pending_wake = Arc::downgrade(&pending_wake); let headroom_wake = Arc::downgrade(&wake); 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!( @@ -3980,14 +4057,16 @@ impl CodeIndexSchedulerRegistryV1 { continue; } owner_headroom_seen = by_others; - } else if let (Some(resident_memory), Some(refused_start)) = ( - headroom_resident_memory.upgrade(), - headroom_refused_reconcile_start.upgrade(), - ) && !refused_start.admits_retry(&resident_memory) - { - continue; } - 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..b8139bdcae 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) @@ -1041,8 +1132,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 +1160,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 +1236,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 +1265,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 +1355,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 +1411,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 +1427,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 +1439,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 +2446,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 +2482,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 +2642,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 +2669,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 +2718,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 +2770,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 +2789,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 +3652,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 +3690,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 +3810,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 f2078f8439..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,27 +1,32 @@ +use std::fmt::Write as _; use std::num::NonZeroU64; use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, OnceLock, Weak}; +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, @@ -1437,29 +1442,212 @@ async fn owner_passes_started(registry: &CodeIndexSchedulerRegistryV1, root: &Pa .started } -/// Issue #4505: a cold build the watermark stops gives back what it built, -/// and the latch clearing behind that release is no memory anyone else gave -/// back. Retrying on it rebuilt the same refused pass forever. +/// 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_at_the_watermark_retries_only_on_memory_it_did_not_give_back() { - const GIB: u64 = 1024 * 1024 * 1024; - let fixture = GitFixture::new(&[("src/main.rs", "fn main() {}\n")]); +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(); - // The process outside the build, plus what the build adds while it holds - // admitted bytes: from 11.5 GiB it crosses the 14.4 GiB high watermark, - // and once it drops them the process is back under the 12 GiB low one. - let outside = Arc::new(AtomicU64::new(11 * GIB + GIB / 2)); 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 outside = Arc::clone(&outside); 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 building = ledger - .get() - .and_then(Weak::upgrade) - .is_some_and(|ledger| ledger.snapshot().used_bytes > 0); - let bytes = outside.load(Ordering::Acquire) + if building { 3 * GIB } else { 0 }; + let bytes = outside.load(Ordering::Acquire); Some(ProcessResidentSampleV1 { resident_bytes: bytes, unreclaimable_bytes: bytes, @@ -1467,17 +1655,15 @@ async fn a_cold_build_refused_at_the_watermark_retries_only_on_memory_it_did_not cgroup_headroom_bytes: None, }) }) - }; - let pressure = Arc::new(ResidentMemoryPressureV1::with_sampler(limit, sampler)); - let resident_memory = Arc::new(ProcessResidentMemoryV1::with_pressure( + })); + let memory = Arc::new(ProcessResidentMemoryV1::with_pressure( limit, Arc::clone(&pressure), )); - ledger.set(Arc::downgrade(&resident_memory)).unwrap(); pressure.sample_and_publish(); - let owners = Arc::new(ResidentOwnersV1::new(IDLE_WINDOW)); - let registry = CodeIndexSchedulerRegistryV1::with_resident_memory(1, resident_memory) - .with_resident_owners(Arc::clone(&owners)); + 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(), @@ -1485,33 +1671,337 @@ async fn a_cold_build_refused_at_the_watermark_retries_only_on_memory_it_did_not store.path().to_path_buf(), ) .await - .expect("mount daemon-owned scheduler"); - let blocked = tokio::time::timeout(Duration::from_secs(30), async { - loop { - if let Some(reason) = registry - .dashboard_freshness(fixture.path()) - .await - .and_then(|freshness| freshness.parked) - .and_then(|parked| parked.blocked_reason) + .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) { - return reason; + 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); } - tokio::time::sleep(Duration::from_millis(50)).await; + 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("the cold build parks on resident memory"); - assert_eq!(blocked, CodeIndexBuildBlockedReasonV1::ResidentMemory); - wait_for_worker_phase(®istry, fixture.path(), CodeIndexWorkerPhaseV1::Parked).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" + ); - // The build's own memory is gone and the latch clears. - let refused = owner_passes_started(®istry, fixture.path()).await; - assert!(!pressure.sample_and_publish().unwrap().1.is_over_budget()); + 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!( - owner_passes_started(®istry, fixture.path()).await, - refused, - "the refused build's own release is not headroom for its retry" + clone_work(), + refused_work, + "banked wakes must not reconstruct inside projection" ); assert!( registry @@ -1519,14 +2009,100 @@ async fn a_cold_build_refused_at_the_watermark_retries_only_on_memory_it_did_not .await .is_none() ); + assert_incumbent(); - // Something else gave memory back: the process now measures below where - // the refused build started, so the build fits and publishes. - outside.store(11 * GIB, Ordering::Release); - pressure.sample_and_publish(); - let text = wait_for_queryable_text_generation(®istry, fixture.path()).await; - assert!(text.query_owners_are_ready()); + 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; } 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(), From dab12b408390013799b790a59687648699c6d0f7 Mon Sep 17 00:00:00 2001 From: Zack Jackson Date: Sat, 24 Oct 2026 22:46:23 -0700 Subject: [PATCH 8/8] fix(code-index): retry superseded publication fences --- .../src/code_index_scheduler/reconcile.rs | 15 +++++++++- .../tests/publication_store.rs | 30 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) 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 8dc2fb2865..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 @@ -4503,7 +4503,20 @@ pub(super) fn discarded_construction_refusal<'r>( discarded: impl IntoIterator, watermark: u64, ) -> CodeIndexSchedulerErrorV1 { - let error = prefer_resident_memory_refusal(control, error.into()); + // 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( 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 b8139bdcae..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 @@ -1100,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