From cdc9aed35c829932cb796db4293fb24f8b94d10f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 24 Aug 2026 23:11:44 +0000 Subject: [PATCH 01/69] docs(perf): design text catch-up pipeline --- ...code-index-text-catchup-pipeline-design.md | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md diff --git a/docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md b/docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md new file mode 100644 index 0000000000..ac5d8598dd --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md @@ -0,0 +1,345 @@ +# Code-Index Text Catch-Up Pipeline Design + +**Date:** 2026-08-24 +**Status:** Approved for implementation +**Scope:** Durable lexical text-artifact construction and its live dashboard projection + +## Problem + +TraceDecay can seal a large code generation without exceeding the process memory +guard, but the subsequent durable text-artifact catch-up is too slow and mostly +invisible. On the 10,592-file TraceDecay corpus, the production journey remained +in `partial_refresh_in_progress` for more than 25 minutes and had processed only +about 17% of files when it was stopped. + +The live baseline was: + +- 34 durable source pages per 30 seconds; +- about 4,230 chunks and 7.7 MiB of sealed payload per 30 seconds; +- 0.64 CPU on a 96-CPU host; +- about 139 MiB/s of writes; +- 7.70 GiB peak RSS, below the 8 GiB safety guard; +- no numeric progress, rate, or ETA in Code or Observatory. + +Hotpath and source evidence agree on the dominant boundary. SQLite repository +execution is the largest timing/allocation owner, followed by page projection +and ordered chunk mapping. The current builder makes that boundary unnecessarily +expensive: + +1. every 128-chunk source page is its own `DELETE`-journal transaction; +2. six secondary indexes are maintained row-by-row during bulk ingestion; +3. 33 row triggers update the same singleton `content_epoch` row for every + insert, update, or delete across eleven mutable tables; +4. JSON projection, token/posting derivation, SQLite insertion, receipt writing, + and commit all happen serially inside the writer transaction; +5. the authenticated build cursor remains private to the staging database, so + the dashboard receives only coarse `indexing`/`refreshing` state. + +At the observed rate, catch-up would take roughly an hour. Reaching five minutes +requires an order-of-magnitude change in transaction and write amplification; +more extraction workers or a larger timeout cannot fix it. + +## Goals + +1. Complete the exact 10,592-file cold text-artifact journey in **five minutes + or less** on the same host and production profile. +2. Keep peak process RSS below **8 GiB** during the journey. +3. Preserve byte-exact query results, source receipts, generation identity, and + final artifact digest semantics. +4. Preserve bounded cancellation, crash recovery, replay idempotence, and + fail-closed corruption handling. +5. Publish truthful live progress, throughput, and estimated remaining time to + Code and Observatory without blocking the scheduler. +6. Add Hotpath evidence at the ownership boundaries needed to distinguish page + preparation, SQLite mutation, commit/journal time, index construction, and + final verification. + +## Non-goals + +- Native graph activation and graph memory planning are not part of this slice. +- Initial source capture, language extraction, and sealed-generation encoding + are already separate measured phases and are not redesigned here. +- Loom remains a session-timeline surface. Code-index construction progress + belongs in Code and Observatory. +- No timeout, resident-memory ceiling, correctness assertion, or durability + policy is weakened to obtain the target. +- The design does not introduce a second query format, shadow progress store, + or test-only production authority. + +## Chosen architecture + +The pipeline becomes a bounded producer/consumer flow with one canonical +durable writer: + +```text +verified sealed source + -> bounded page batch + -> parallel deterministic page preparation + -> one ordered SQLite bulk transaction + -> durable per-page receipts + source cursor + -> bounded offline index construction + -> existing two-pass verification and atomic publication +``` + +The sealed source, SQLite artifact, per-page receipts, and final receipt remain +the only durable authorities. Parallel work produces deterministic values; it +never publishes progress or owns durability. + +### 1. Bounded page batches + +The verified sealed source gains a batch admission operation. It stages ordered +pages against a working cursor and advances its real cursor only after the +builder accepts the whole batch. If preparation, SQLite mutation, cancellation, +or commit fails, the source restores the exact pre-batch cursor and yields the +same pages on retry. + +Batch admission is byte-bound, not merely count-bound: + +- the retained bytes of every staged page are summed; +- the largest per-record transient projection bound is charged once because + records are materialized serially within each worker; +- source decode-window bytes, builder metadata, and SQLite cache authority stay + charged by the existing resident-memory reservation; +- batch size shrinks rather than exceeding the 256 MiB builder ceiling. + +The initial target is up to 16 pages or 32 MiB of sealed payload, whichever +bound is reached first. These are work-unit ceilings, not tuning promises; the +memory ledger remains the admission authority. + +### 2. Deterministic preparation outside the writer transaction + +Each page is converted into a prepared relational page before opening the +SQLite transaction. Preparation owns: + +- projected artifact rows and their JSON bytes; +- document integrity digests; +- term postings and frequency deltas; +- exact and n-gram postings; +- import evidence and integrity digests; +- vocabulary and field-statistic deltas; +- the exact source-page receipt row. + +Preparation is pure with respect to persistent state. Pages may be prepared on +the canonical bounded CPU pool, but the ordered result vector is keyed by page +ordinal and is admitted only when contiguous from the builder's durable cursor. +No dynamic task labels or unbounded per-page metric identities are created. + +### 3. One ordered bulk transaction per batch + +The writer consumes prepared pages in ordinal order inside one transaction. It +uses cached statements for the whole batch and writes every page's source +receipt in the same transaction as its derived rows. The transaction commits +only after the final page receipt is written and cancellation is checked. + +The existing one-page API becomes a one-element wrapper over the batch API so +all callers share one correctness path. + +A successful commit advances progress to the final receipt cursor. A failed +commit advances neither SQLite progress nor the sealed-source cursor. Recovery +continues from the last committed page exactly as it does today. + +### 4. Remove bulk-load mutation amplification + +The mutable staging schema no longer installs row-level `content_epoch` +triggers while source pages are being loaded. Those triggers currently turn +millions of row inserts into millions of additional updates of one hot row. + +The epoch guard is still required while bounded finalization spans multiple +transactions. Therefore finalization starts with one atomic transition: + +1. acquire the writer transaction; +2. install the mutation-detection triggers; +3. capture the epoch; +4. persist the finalization cursor; +5. commit. + +After that transition source pages are immutable, the builder rejects further +append operations, and any external SQLite mutation advances the epoch and is +refused by the next bounded finalization wake. Existing pre-finalization +integrity digests continue to reject self-attesting derived corruption. + +This preserves the mutation contract while removing the trigger cost from the +only bulk-load phase. + +### 5. Build secondary indexes after ingestion + +Secondary indexes needed only for serving and verification are omitted from the +initial staging schema. Their construction becomes an explicit, durable +finalization phase after all source pages are committed. + +Index construction must remain interruptible and resumable. A single unbounded +`CREATE INDEX` over the corpus is not acceptable. The implementation therefore +builds index shadow tables in bounded key-ordered slices with persisted cursors, +then performs a short atomic name swap. The final schema and query plans remain +the same as the current artifact contract. + +Primary keys and uniqueness constraints required to reject duplicate or +out-of-order input remain active during ingestion. Only redundant serving +indexes move to finalization. + +The first implementation checkpoint may land batching and trigger removal +before deferred indexes, but the five-minute acceptance is not claimed until +the complete design is measured. + +### 6. Live progress authority + +Every committed batch publishes one immutable in-memory progress snapshot tied +to the exact generation and sealed-source identity. The mounted registry owns +an `Arc` snapshot slot separate from the scheduler mutex, so dashboard reads do +not block behind a long reconcile. + +The snapshot contains: + +- phase: source scan, relational preparation, bulk commit, index build, + verification, or ready; +- generation and sealed-source digest; +- committed pages, chunks, imports, and payload bytes; +- completed and total file ordinals; +- completed and total sealed lexical byte span; +- current batch size and last commit duration; +- monotonic elapsed time for the current process; +- rolling throughput over committed bytes and files; +- estimated remaining seconds when at least two samples establish a positive + rate; +- last-progress timestamp and an optional typed blocked reason. + +Progress is exact at committed batch boundaries. It never reports staged or +prepared work as durable. The percentage is based on authenticated sealed byte +and file boundaries, not database size or an inferred chunk total. ETA is +explicitly an estimate and is absent when no truthful rate exists. + +The slot is cleared or replaced on generation supersession and survives no +process restart. On restart, the first snapshot is reconstructed from the +durable source-page cursor before new work begins. + +### 7. Dashboard projection + +`GET /api/code-index/freshness` gains an optional generation-scoped progress +object. Existing readiness and authorization states remain unchanged. + +Code renders: + +- phase and exact percentage; +- files, pages, chunks, and payload committed; +- current throughput; +- estimated remaining time; +- last progress age and typed blocked reason. + +Observatory renders the same authority as a compact pipeline card with phase +durations and commit throughput. Both surfaces refresh every second while a +build is active and return to the existing slower cadence when ready. Rendering +does not advance the build, acquire the scheduler mutex, or infer missing data. + +## Hotpath instrumentation + +Instrumentation is static and bounded per the pinned Hotpath 0.24 contract. +No generation, path, page ordinal, or batch ordinal appears in a metric label. + +Required timing spans: + +- `query.artifact.batch.source` +- `query.artifact.batch.prepare` +- `query.artifact.batch.sqlite` +- `query.artifact.batch.imports` +- `query.artifact.batch.rows` +- `query.artifact.batch.receipts` +- `query.artifact.batch.commit` +- `query.artifact.index.build` +- `query.artifact.finalization.verify` +- `dashboard.code_index.progress` + +Required gauges/counters: + +- active prepared bytes and pages; +- committed pages, rows, and payload bytes; +- SQLite commits and rollback count; +- rows written by table family; +- index rows built and current index phase; +- latest commit latency; +- progress publication count and age. + +Allocation profiling remains a separate diagnostic run. The five-minute and +8-GiB requirements are established by wall-clock and OS RSS evidence, not by +Hotpath totals. + +## Failure and cancellation semantics + +- Cancellation before commit rolls back the transaction and restores the + source cursor. +- Cancellation after commit returns the committed durable progress; retry + starts at the next page. +- A crash during a batch leaves SQLite at the prior transaction boundary. +- A corrupt or non-contiguous receipt refuses resume; it is never skipped. +- A generation change cancels preparation, drops uncommitted pages, and cannot + publish progress under the new generation identity. +- A dashboard reader sees either the previous complete snapshot or the next + complete snapshot; it never observes a partially updated struct. +- Index construction resumes from its persisted finalization cursor and does + not rebuild already verified slices. + +## Alternatives considered + +### Tune SQLite pragmas only + +Rejected. The live run is dominated by deliberate row and transaction +amplification. Larger caches, `WAL`, or weaker synchronization do not remove +millions of hot-row updates or online index maintenance, and changing durability +would violate the artifact contract. + +### One SQLite database per worker followed by merge + +Rejected for this slice. It can parallelize writes but creates a second merge +authority, duplicates schema and receipt logic, and makes crash recovery more +complex. The canonical ordered writer is sufficient once preparation and +transaction amplification are separated. + +### Build the serving artifact during initial extraction + +Deferred. It could eliminate the sealed-source replay, but it couples generation +publication to one query format and would make query-artifact failure block a +valid sealed generation. The current separation is valuable; this design makes +the replay bounded and fast instead. + +## Verification + +### Correctness tests + +- one-page behavior remains byte- and receipt-equivalent; +- multi-page commit publishes every page atomically and contiguously; +- cancellation during preparation and during SQLite mutation leaves the source + and builder at the pre-batch cursor; +- cancellation after commit resumes at the next exact page; +- crash/reopen after each batch boundary yields the same final artifact digest; +- inter-wake mutation remains a typed corruption after finalization installs + epoch triggers; +- parallel preparation produces the same ordered relational values as serial + preparation; +- deferred index construction produces the existing required index inventory + and query plans; +- dashboard progress is generation-scoped, monotonic, nonblocking, and absent + when no mounted build exists. + +### Performance tests + +1. A focused builder benchmark compares one-page and bounded-batch ingestion on + the same deterministic page set and records rows, commits, bytes written, + and wall time. +2. A high-posting fixture proves per-row epoch updates are eliminated during + ingestion. +3. The production 10,592-file isolated journey is rerun from a cold store with + native graph disabled and the same resident-memory authority. + +The production acceptance is: + +- text artifact ready in at most 300 seconds; +- peak RSS below 8 GiB; +- exact generation ID and final artifact digest match the baseline source; +- exact/lexical search returns the expected symbols; +- restart from a deliberately stopped intermediate batch resumes without + replaying committed pages; +- Code and Observatory show monotonic progress at one-second cadence; +- no dynamic Hotpath identities and feature-on/feature-off builds both pass. + +If the full journey remains above five minutes, the branch must report the +measured residual phase and continue optimizing it. A partial speedup is not +accepted as completion of this design. From 018b5ffac1b4e81157a90cc1851216d6999babdb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 24 Aug 2026 23:18:14 +0000 Subject: [PATCH 02/69] docs(perf): plan text catch-up implementation --- ...-08-24-code-index-text-catchup-pipeline.md | 537 ++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-code-index-text-catchup-pipeline.md diff --git a/docs/superpowers/plans/2026-08-24-code-index-text-catchup-pipeline.md b/docs/superpowers/plans/2026-08-24-code-index-text-catchup-pipeline.md new file mode 100644 index 0000000000..c4d17e8c86 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-code-index-text-catchup-pipeline.md @@ -0,0 +1,537 @@ +# Code-Index Text Catch-Up Pipeline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the durable lexical text-artifact pipeline complete the 10,592-file cold TraceDecay corpus in at most five minutes under 8 GiB RSS while preserving exact recovery and publishing live progress. + +**Architecture:** The verified sealed source stages memory-bounded page batches, deterministic preparation runs before one ordered SQLite transaction, and the writer commits all derived rows plus per-page receipts atomically. A generation-scoped snapshot slot publishes only committed progress to the dashboard without taking the scheduler mutex. + +**Tech Stack:** Rust 2024, Tokio, rusqlite/SQLite, Hotpath 0.24, Axum/schemars, React, TanStack Query, Vitest, Criterion. + +**Spec:** `docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md` + +## Global Constraints + +- Production acceptance is at most 300 seconds and less than 8 GiB RSS on the exact isolated 10,592-file corpus. +- Per-page receipts, cursor transitions, final artifact digest, and query results remain exact. +- Cancellation or failure before commit advances neither SQLite nor the sealed-source cursor. +- Hotpath labels are static; generation/path/page/batch identities never become labels. +- Progress reports committed boundaries only and never blocks on the scheduler mutex. +- No timeout, resident-memory ceiling, durability setting, or correctness assertion is weakened. +- Root is the sole shared Cargo, dashboard codegen, and contract-generation coordinator. + +--- + +### Task 1: Atomic sealed-source page batches + +**Files:** +- Modify: `crates/tracedecay-code-index/src/production/lexical_page_source.rs` + +**Interfaces:** +- Consumes: `VerifiedSealedLexicalPageV1`, `VerifiedSealedLexicalCursorV1`, and the existing `stage_next_page` transition. +- Produces: `VerifiedSealedLexicalPageBatchBoundsV1`, `VerifiedSealedLexicalPageBatchReadV1`, and `VerifiedSealedLexicalPageSourceV1::next_page_batch_if`. + +- [ ] **Step 1: Write the callback-refusal regression** + +Add a real source test that records the initial cursor, asks for four pages, +returns a literal `"reject-batch"` from the callback, and then reads one page +through `next_page`. Assert that the page ordinal and cumulative digest equal +the hand-recorded first page rather than the fifth page. + +```rust +let before = source.cursor().clone(); +let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new(4, 32 * 1024 * 1024) + .expect("valid batch bounds"); +let refused = source.next_page_batch_if(&control, bounds, |_| { + Err::<(), _>("reject-batch") +}); +assert!(matches!(refused, Ok(Err("reject-batch")))); +assert_eq!(source.cursor(), &before); +assert_eq!(next_page.page_ordinal(), 0); +``` + +- [ ] **Step 2: Run the exact test and observe RED** + +Run: + +```bash +scripts/require-exact-test.sh cargo test -p tracedecay-code-index --lib --locked \ + production::lexical_page_source::tests::batch_rejection_restores_the_exact_source_cursor -- --exact +``` + +Expected: compilation fails because `next_page_batch_if` does not exist. + +- [ ] **Step 3: Implement bounded batch staging** + +Add the typed batch read and API: + +```rust +pub enum VerifiedSealedLexicalPageBatchReadV1 { + Pages(Vec), + Complete(VerifiedSealedLexicalSourceReceiptV1), +} + +pub struct VerifiedSealedLexicalPageBatchBoundsV1 { + maximum_pages: usize, + maximum_retained_bytes: usize, +} + +pub fn next_page_batch_if( + &mut self, + control: &dyn CodeIndexExecutionControlV1, + bounds: VerifiedSealedLexicalPageBatchBoundsV1, + admit: impl FnOnce(&[VerifiedSealedLexicalPageV1]) -> Result<(), E>, +) -> Result, CodeIndexProductionErrorV1>; +``` + +Require nonzero bounds. Save the exact initial cursor, stage contiguous pages, +stop before the next page would exceed either bound, call `admit` once, and +assign the final working cursor only after success. On callback error, restore +the saved cursor. Completion after staged pages is returned on the next call so +the callback never receives an empty batch. + +- [ ] **Step 4: Add boundary and completion tests** + +Cover one-page count bound, byte-bound stop, exact ordinal order, completion +after the last accepted batch, and cancellation during staging. Derive expected +page ordinals and digests from literal first/last fixtures rather than the new +batch method. + +- [ ] **Step 5: Run the source slice and commit** + +```bash +cargo test -p tracedecay-code-index --lib --locked production::lexical_page_source::tests:: +git add crates/tracedecay-code-index/src/production/lexical_page_source.rs +git commit -m 'perf(index): batch sealed lexical pages' +``` + +### Task 2: Atomic multi-page artifact append and trigger-free bulk load + +**Files:** +- Modify: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs` +- Modify: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs` +- Create: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs` +- Modify: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs` +- Test: `crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs` + +**Interfaces:** +- Consumes: ordered `&[VerifiedSealedLexicalPageV1]` from Task 1. +- Produces: bounded `PreparedCodeLexicalArtifactPageV1`, `prepare_pages`, `append_prepared_pages`, and `CodeLexicalArtifactBuilderV1::append_pages`; one-page append delegates to the same path. + +- [ ] **Step 1: Write the rollback RED** + +Stage two valid pages and one page with a foreign generation in one batch. Assert +`append_pages` returns `Contract` and `progress()` remains exactly the initial +zero progress. Then append the two valid pages and assert page count two. + +```rust +let before = builder.progress().expect("initial progress"); +assert!(matches!( + builder.append_pages(&[page0.clone(), page1.clone(), foreign], &control), + Err(CodeLexicalArtifactErrorV1::Contract(_)) +)); +assert_eq!(builder.progress().expect("rolled back progress"), before); +``` + +- [ ] **Step 2: Run RED exactly** + +```bash +scripts/require-exact-test.sh cargo test -p tracedecay-query --test search_quality_suite --locked \ + candidate_producers::disk_artifact_batch_is_atomic_and_replay_exact -- --exact +``` + +Expected: compilation fails because `append_pages` does not exist. + +- [ ] **Step 3: Implement batch memory admission** + +Compute the batch charge before opening SQLite: + +```rust +needed = fixed_ledger_charge_bytes + + pages.iter().map(VerifiedSealedLexicalPageV1::retained_owned_bytes).sum::() + + prepared.iter().map(PreparedCodeLexicalArtifactPageV1::retained_owned_bytes).sum::() + + pages + .iter() + .map(|page| page_preparation_scratch_bytes(page, usize::MAX)) + .collect::, _>>()? + .into_iter() + .max() + .unwrap_or(0); +``` + +Use checked arithmetic and return a typed contract refusal before mutation. +Validate contiguous ordinals, transitions, and cumulative digests for every +page against a working progress/cursor. A typed pre-SQLite batch-too-large +refusal lets the scheduler shrink the batch and retry from the unchanged source +cursor. + +- [ ] **Step 4: Prepare deterministic relational pages outside SQLite** + +Move projection, JSON encoding, integrity hashing, frequency aggregation, exact +postings, n-grams, imports, vocabulary, statistics deltas, and the source-page +receipt into `prepared.rs`. The prepared type owns values only; it cannot open +SQLite or publish progress. It records a checked retained-memory charge. + +```rust +pub fn prepare_pages( + &self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result, CodeLexicalArtifactErrorV1>; +``` + +- [ ] **Step 5: Implement one ordered SQLite transaction** + +Add: + +```rust +pub fn append_pages( + &mut self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result; + +pub fn append_prepared_pages( + &mut self, + pages: &[PreparedCodeLexicalArtifactPageV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result; +``` + +`append_pages` prepares serially as the compatibility path and delegates to +`append_prepared_pages`. Open one transaction after all admission checks. Append imports, derived rows, +and one source receipt per page in ordinal order. Check cancellation before each +page and immediately before commit. Read progress once after commit. Implement +`append_page` as `self.append_pages(std::slice::from_ref(page), control)`. + +- [ ] **Step 6: Move epoch triggers to finalization admission** + +Remove the 33 `CREATE TRIGGER` statements from `create_schema`. Add +`install_finalization_mutation_triggers(&Transaction)` and call it in the same +transaction that creates the first `PersistedFinalizationStateV1`. Capture the +epoch only after triggers are installed. Bump format revision from 5 to 6 so an +old partial staging artifact is discarded instead of interpreted under the new +schema lifecycle. + +- [ ] **Step 7: Add bounded static instrumentation** + +Wrap the fixed boundaries only: + +```rust +hotpath::measure_block!("query.artifact.batch.imports", { + write_prepared_imports(&transaction, pages, control)? +}); +hotpath::measure_block!("query.artifact.batch.rows", { + write_prepared_rows(&transaction, pages, control)? +}); +hotpath::measure_block!("query.artifact.batch.receipts", { + write_prepared_receipts(&transaction, pages)? +}); +hotpath::measure_block!("query.artifact.batch.commit", { + transaction.commit().map_err(sqlite_error)? +}); +``` + +Increment static gauges for committed batches/pages/chunks and rollback count. +No metric label includes an ordinal or identity. + +- [ ] **Step 8: Prove preparation, mutation, and recovery contracts** + +Prepare the same literal page through serial and ordered-parallel callers, +append each to a fresh artifact, and assert equal receipts, digests, and +representative query rows. + +Run the new batch test plus: + +```bash +scripts/require-exact-test.sh cargo test -p tracedecay-query --test search_quality_suite --locked \ + candidate_producers::disk_artifact_finalization_refuses_inter_wake_mutation -- --exact +scripts/require-exact-test.sh cargo test -p tracedecay-query --test search_quality_suite --locked \ + candidate_producers::disk_artifact_first_finalize_rejects_self_attesting_derived_mutation -- --exact +``` + +- [ ] **Step 9: Commit the builder slice** + +```bash +git add crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs \ + crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs \ + crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs \ + crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs \ + crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +git commit -m 'perf(query): bulk append lexical artifact pages' +``` + +### Task 3: Scheduler batching and nonblocking progress authority + +**Files:** +- Modify: `src/daemon/code_index_scheduler.rs` +- Modify: `src/daemon/code_index_scheduler/registry.rs` +- Test: `src/daemon/code_index_scheduler/tests.rs` + +**Interfaces:** +- Consumes: `next_page_batch_if`, `prepare_pages`, and `append_prepared_pages` from Tasks 1-2. +- Produces: `CodeIndexBuildProgressV1` snapshots and an O(1) mounted progress slot. + +- [ ] **Step 1: Write the uncommitted-progress RED** + +Use a real mounted scheduler with a control that cancels inside batch append. +Assert the dashboard snapshot remains at the prior committed page and source +cursor. A second non-cancelled advance must publish the batch once. + +- [ ] **Step 2: Run RED exactly** + +```bash +scripts/require-exact-test.sh cargo test -p tracedecay --lib --locked \ + daemon::code_index_scheduler::tests::dashboard_progress_advances_only_after_durable_batch_commit -- --exact +``` + +- [ ] **Step 3: Add the mounted snapshot slot** + +Store `Arc>>` beside +`serving_generation` in `MountedCodeIndexWorktreeV1`. Clone that slot in +`dashboard_freshness` before entering `spawn_blocking`; never acquire the +scheduler mutex to read it. + +- [ ] **Step 4: Drive batches through the scheduler** + +Replace the page-at-a-time loop with `next_page_batch_if` using a maximum of 16 pages +and 32 MiB sealed payload per batch, while still clamping total wake operations +to 64. Prepare pages through the existing canonical bounded CPU authority, keep +the result in page-ordinal order, then call `builder.append_prepared_pages` on +the single writer. On the typed pre-SQLite batch-too-large refusal, halve the +page bound and retry from the unchanged source cursor; do not retry any other +error. Publish a snapshot only after the source and writer both succeed. + +- [ ] **Step 5: Compute truthful rate and ETA** + +Keep two committed samples `(Instant, completed_lexical_bytes)`. Publish rate +only when elapsed and byte delta are positive. Compute ETA as remaining exact +sealed lexical span divided by that rate. Clear samples on generation change; +reconstruct counts/cursor from builder progress after reopen but leave rate and +ETA absent until a second process-local sample exists. + +- [ ] **Step 6: Prove nonblocking and supersession behavior** + +Add tests that hold the scheduler mutex while `dashboard_freshness` completes, +replace the generation and observe the old snapshot disappear, and reopen an +intermediate staging artifact with exact committed counts but no fabricated +rate. + +- [ ] **Step 7: Commit runtime integration** + +```bash +git add src/daemon/code_index_scheduler.rs \ + src/daemon/code_index_scheduler/registry.rs \ + src/daemon/code_index_scheduler/tests.rs +git commit -m 'feat(index): publish live catch-up progress' +``` + +### Task 4: Freshness contract and Code progress UI + +**Files:** +- Modify: `crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs` +- Modify: `dashboard/stories/fixtures/data.ts` +- Modify: `dashboard/src/workspaces/code/IndexFreshness.tsx` +- Test: `dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx` +- Modify: `dashboard/src/workspaces/observatory/ObservatoryPage.tsx` +- Test: `dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx` +- Test: `dashboard/src/workspaces/endpoint-fixtures.test.ts` +- Generate: `dashboard/src/contracts/generated.ts` + +**Interfaces:** +- Consumes: runtime `CodeIndexBuildProgressV1` from Task 3. +- Produces: optional `build_progress` in `CodeIndexWorktreeFreshnessV1`. + +- [ ] **Step 1: Add failing active-progress fixtures** + +Use literal active progress values: 250/1,000 files, 400/1,600 lexical bytes, +40 bytes/s, ETA 30 seconds. Assert accessible progress value 25%, visible +counts/rate/ETA, and one-second active polling. Add a second fixture with null +rate/ETA and assert `measuring rate` rather than zero. + +- [ ] **Step 2: Run focused RED** + +```bash +cd dashboard +npm test -- src/workspaces/code/IndexFreshness.dom.test.tsx \ + src/workspaces/observatory/ObservatoryPage.dom.test.tsx \ + src/workspaces/endpoint-fixtures.test.ts +``` + +- [ ] **Step 3: Add the Rust wire type** + +Define a schemars/serde type whose integer counters are lossless in the existing +JSON contract and add `build_progress: Option` to the +worktree payload. Include generation, phase, committed/total file and lexical +byte bounds, page/chunk/import/payload counts, optional rate/ETA, commit latency, +last progress micros, and optional blocked reason. + +- [ ] **Step 4: Render active progress without inference** + +Render a native `` from exact lexical bytes when total is nonzero, +with file percentage as supporting text. Show rate/ETA only when supplied. +Change TanStack `refetchInterval` to a function returning 1,000 ms only when a +worktree has active progress and 30,000 ms otherwise. + +Render the same component and wire authority in Observatory as a compact +pipeline card; do not introduce a second fetch or a parallel progress model. + +- [ ] **Step 5: Regenerate and verify contracts** + +```bash +cd dashboard +npm run contracts:generate +npm run contracts:check +npm run typecheck +npm test -- src/workspaces/code/IndexFreshness.dom.test.tsx \ + src/workspaces/observatory/ObservatoryPage.dom.test.tsx \ + src/workspaces/endpoint-fixtures.test.ts +``` + +- [ ] **Step 6: Commit the dashboard slice** + +```bash +git add crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs \ + dashboard/stories/fixtures/data.ts dashboard/src/contracts/generated.ts \ + dashboard/src/workspaces/code/IndexFreshness.tsx \ + dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx \ + dashboard/src/workspaces/observatory/ObservatoryPage.tsx \ + dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx \ + dashboard/src/workspaces/endpoint-fixtures.test.ts +git commit -m 'feat(dashboard): show live index catch-up progress' +``` + +### Task 5: Deterministic ingestion benchmark + +**Files:** +- Create: `crates/tracedecay-query/benches/code_lexical_artifact_catchup.rs` +- Modify: `crates/tracedecay-query/Cargo.toml` + +**Interfaces:** +- Consumes: public one-page and batch append APIs. +- Produces: Criterion comparison with digest/receipt equivalence assertions. + +- [ ] **Step 1: Build one deterministic page corpus** + +Construct the same verified pages once per benchmark input outside the timed +closure. Each timed iteration creates a fresh private artifact and uses either +one-page append or batches of 16. + +- [ ] **Step 2: Assert equivalence outside timing** + +Before registering benchmarks, build both artifacts, finalize them, and assert +literal equality for page/chunk/payload counts, final artifact digest, and +representative exact/lexical query results. + +- [ ] **Step 3: Register and run the benchmark** + +```bash +cargo bench -p tracedecay-query --bench code_lexical_artifact_catchup --profile perf +``` + +Record median time and throughput for both paths; do not add a hard-coded CI +speed threshold. + +- [ ] **Step 4: Commit the harness** + +```bash +git add crates/tracedecay-query/Cargo.toml \ + crates/tracedecay-query/benches/code_lexical_artifact_catchup.rs +git commit -m 'bench(query): measure lexical artifact batching' +``` + +### Task 6: Deferred serving-index construction + +**Files:** +- Modify: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs` +- Modify: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs` +- Test: `crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs` + +**Interfaces:** +- Consumes: persisted bounded finalization cursor. +- Produces: the existing six named serving indexes with unchanged columns and query plans. + +- [ ] **Step 1: Measure the remaining online-index cost** + +Run the Task 5 benchmark and a Hotpath feature-on pass after Tasks 1-5. Proceed +only if online index maintenance remains a material owner or the production +journey exceeds 300 seconds. + +- [ ] **Step 2: Write the bounded restart RED** + +Cancel index construction after one persisted key-ordered slice. Reopen the +artifact and assert it resumes after the exact last key, produces every expected +index row once, and finishes with the literal six-index inventory. + +- [ ] **Step 3: Implement shadow index tables in bounded slices** + +Populate shadow index tables using the existing finalization transaction and +row budget. Persist section and key cursor after every slice. When complete, +create the final named indexes over the bounded shadow representation and swap +them atomically. Do not use one unbounded corpus-wide `CREATE INDEX` as the work +unit. + +- [ ] **Step 4: Verify query plans and recovery** + +Run exact query-plan, cancellation, restart, corrupt-index, and artifact-reader +tests. Confirm every existing `INDEXED BY` statement resolves the same named +index and column order. + +- [ ] **Step 5: Commit only if measured** + +```bash +git add crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs \ + crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs \ + crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +git commit -m 'perf(query): defer lexical serving indexes' +``` + +### Task 7: Production acceptance and PR + +**Files:** +- No production edits during measurement. + +**Interfaces:** +- Consumes: exact committed branch head from Tasks 1-6. +- Produces: reproducible cold/resume evidence and PR into the integration branch. + +- [ ] **Step 1: Run static and focused gates** + +```bash +cargo fmt --all -- --check +git diff --check +cargo check -p tracedecay-code-index -p tracedecay-query -p tracedecay-dashboard-api --all-features --locked +cd dashboard && npm run contracts:check && npm run typecheck +``` + +- [ ] **Step 2: Build the production feature profile** + +Resolve the repository's canonical production release features and build with +the `perf` profile. Record the exact binary SHA-256 and source commit. + +- [ ] **Step 3: Run cold, resume, and settled journeys** + +Use the isolated 10,592-file fixture. Record wall time, one-second RSS, CPU, +I/O, Hotpath timing, committed progress snapshots, generation ID, artifact +digest, and expected search hits. Stop once mid-build, restart, and prove the +committed cursor resumes without replay. + +- [ ] **Step 4: Enforce acceptance** + +Do not declare success unless text readiness is at most 300 seconds and peak RSS +is below 8 GiB. If it misses, identify the residual measured phase and return to +the corresponding task rather than raising the deadline. + +- [ ] **Step 5: Push and open the PR** + +```bash +git push -u origin codex/code-index-catchup-pipeline +gh pr create \ + --base codex/tracedecay-total-redesign-plan-reopened \ + --head codex/code-index-catchup-pipeline \ + --title 'perf(index): accelerate durable text catch-up' \ + --body-file /tmp/code-index-catchup-pr-body.md +``` + +The PR body includes the cold/resume evidence, exact gates, residual risks, and +the fact that Loom remains intentionally session-only. From 890ad94883516f854cfde8e63a4f6c55ab81f145 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 24 Aug 2026 23:26:55 +0000 Subject: [PATCH 03/69] docs(perf): tighten catch-up safety contracts --- ...-08-24-code-index-text-catchup-pipeline.md | 82 ++++------- ...code-index-text-catchup-pipeline-design.md | 130 +++++++++--------- 2 files changed, 98 insertions(+), 114 deletions(-) diff --git a/docs/superpowers/plans/2026-08-24-code-index-text-catchup-pipeline.md b/docs/superpowers/plans/2026-08-24-code-index-text-catchup-pipeline.md index c4d17e8c86..605475f46d 100644 --- a/docs/superpowers/plans/2026-08-24-code-index-text-catchup-pipeline.md +++ b/docs/superpowers/plans/2026-08-24-code-index-text-catchup-pipeline.md @@ -105,7 +105,7 @@ git add crates/tracedecay-code-index/src/production/lexical_page_source.rs git commit -m 'perf(index): batch sealed lexical pages' ``` -### Task 2: Atomic multi-page artifact append and trigger-free bulk load +### Task 2: Atomic multi-page artifact append with the mutation fence preserved **Files:** - Modify: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs` @@ -150,16 +150,15 @@ Compute the batch charge before opening SQLite: needed = fixed_ledger_charge_bytes + pages.iter().map(VerifiedSealedLexicalPageV1::retained_owned_bytes).sum::() + prepared.iter().map(PreparedCodeLexicalArtifactPageV1::retained_owned_bytes).sum::() - + pages + + active_workers .iter() - .map(|page| page_preparation_scratch_bytes(page, usize::MAX)) - .collect::, _>>()? - .into_iter() - .max() - .unwrap_or(0); + .map(page_preparation_scratch_bytes) + .sum::() + + task_overhead; ``` -Use checked arithmetic and return a typed contract refusal before mutation. +Use checked arithmetic, independent prepared-row/estimated-write ceilings, and +return a typed batch-too-large refusal before mutation. Validate contiguous ordinals, transitions, and cumulative digests for every page against a working progress/cursor. A typed pre-SQLite batch-too-large refusal lets the scheduler shrink the batch and retry from the unchanged source @@ -204,14 +203,13 @@ and one source receipt per page in ordinal order. Check cancellation before each page and immediately before commit. Read progress once after commit. Implement `append_page` as `self.append_pages(std::slice::from_ref(page), control)`. -- [ ] **Step 6: Move epoch triggers to finalization admission** +- [ ] **Step 6: Preserve and prove the ingestion-time mutation fence** -Remove the 33 `CREATE TRIGGER` statements from `create_schema`. Add -`install_finalization_mutation_triggers(&Transaction)` and call it in the same -transaction that creates the first `PersistedFinalizationStateV1`. Capture the -epoch only after triggers are installed. Bump format revision from 5 to 6 so an -old partial staging artifact is discarded instead of interpreted under the new -schema lifecycle. +Keep the existing epoch triggers active from staging creation through +finalization. Add a pre-finalization self-attesting corruption test that changes +derived rows or postings and rewrites the public integrity digest while keeping +row counts stable; finalization must still return typed corruption. Do not bump +the format solely for batching. - [ ] **Step 7: Add bounded static instrumentation** @@ -313,9 +311,10 @@ ETA absent until a second process-local sample exists. - [ ] **Step 6: Prove nonblocking and supersession behavior** Add tests that hold the scheduler mutex while `dashboard_freshness` completes, -replace the generation and observe the old snapshot disappear, and reopen an -intermediate staging artifact with exact committed counts but no fabricated -rate. +barrier an old generation immediately before publication, supersede it, and +prove an epoch CAS prevents the old worker from overwriting or clearing the new +snapshot. Reopen an intermediate staging artifact with exact committed counts +but no fabricated rate. - [ ] **Step 7: Commit runtime integration** @@ -440,16 +439,15 @@ git add crates/tracedecay-query/Cargo.toml \ git commit -m 'bench(query): measure lexical artifact batching' ``` -### Task 6: Deferred serving-index construction +### Task 6: Measure the retained serving-index cost **Files:** -- Modify: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs` -- Modify: `crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs` -- Test: `crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs` +- No production schema edits in this slice. **Interfaces:** -- Consumes: persisted bounded finalization cursor. -- Produces: the existing six named serving indexes with unchanged columns and query plans. +- Consumes: Task 5 benchmark and Hotpath evidence. +- Produces: a measured decision while retaining the existing six native indexes + and query plans unchanged. - [ ] **Step 1: Measure the remaining online-index cost** @@ -457,34 +455,14 @@ Run the Task 5 benchmark and a Hotpath feature-on pass after Tasks 1-5. Proceed only if online index maintenance remains a material owner or the production journey exceeds 300 seconds. -- [ ] **Step 2: Write the bounded restart RED** - -Cancel index construction after one persisted key-ordered slice. Reopen the -artifact and assert it resumes after the exact last key, produces every expected -index row once, and finishes with the literal six-index inventory. - -- [ ] **Step 3: Implement shadow index tables in bounded slices** - -Populate shadow index tables using the existing finalization transaction and -row budget. Persist section and key cursor after every slice. When complete, -create the final named indexes over the bounded shadow representation and swap -them atomically. Do not use one unbounded corpus-wide `CREATE INDEX` as the work -unit. - -- [ ] **Step 4: Verify query plans and recovery** +- [ ] **Step 2: Preserve the current contract** -Run exact query-plan, cancellation, restart, corrupt-index, and artifact-reader -tests. Confirm every existing `INDEXED BY` statement resolves the same named -index and column order. - -- [ ] **Step 5: Commit only if measured** - -```bash -git add crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs \ - crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs \ - crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs -git commit -m 'perf(query): defer lexical serving indexes' -``` +SQLite cannot incrementally populate a native index through shadow tables and +atomically rename it into place. Keep the current native index inventory and +all `INDEXED BY` query plans unchanged. If online maintenance remains a material +owner after batching, write a separate design that either accepts a measured +monolithic engine operation or deliberately changes the lookup-table contract; +do not smuggle either choice into this slice. ### Task 7: Production acceptance and PR @@ -492,7 +470,7 @@ git commit -m 'perf(query): defer lexical serving indexes' - No production edits during measurement. **Interfaces:** -- Consumes: exact committed branch head from Tasks 1-6. +- Consumes: exact committed branch head and measurement from Tasks 1-6. - Produces: reproducible cold/resume evidence and PR into the integration branch. - [ ] **Step 1: Run static and focused gates** diff --git a/docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md b/docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md index ac5d8598dd..a9b2fc0ca5 100644 --- a/docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md +++ b/docs/superpowers/specs/2026-08-24-code-index-text-catchup-pipeline-design.md @@ -77,7 +77,7 @@ verified sealed source -> parallel deterministic page preparation -> one ordered SQLite bulk transaction -> durable per-page receipts + source cursor - -> bounded offline index construction + -> existing bounded finalization -> existing two-pass verification and atomic publication ``` @@ -96,8 +96,12 @@ same pages on retry. Batch admission is byte-bound, not merely count-bound: - the retained bytes of every staged page are summed; -- the largest per-record transient projection bound is charged once because - records are materialized serially within each worker; +- the retained capacities of every prepared page are summed while the borrowed + sealed-page batch remains live; +- preparation scratch is charged for every concurrently active worker, along + with bounded task overhead; +- prepared row count and estimated SQLite write bytes have independent ceilings + so source bytes cannot hide unbounded relational expansion or commit latency; - source decode-window bytes, builder metadata, and SQLite cache authority stay charged by the existing resident-memory reservation; - batch size shrinks rather than exceeding the 256 MiB builder ceiling. @@ -122,6 +126,9 @@ SQLite transaction. Preparation owns: Preparation is pure with respect to persistent state. Pages may be prepared on the canonical bounded CPU pool, but the ordered result vector is keyed by page ordinal and is admitted only when contiguous from the builder's durable cursor. +Preparation uses structured concurrency: cancellation is observed by every +worker and every admitted worker is joined before the call and its memory +reservation return. No dynamic task labels or unbounded per-page metric identities are created. ### 3. One ordered bulk transaction per batch @@ -129,7 +136,9 @@ No dynamic task labels or unbounded per-page metric identities are created. The writer consumes prepared pages in ordinal order inside one transaction. It uses cached statements for the whole batch and writes every page's source receipt in the same transaction as its derived rows. The transaction commits -only after the final page receipt is written and cancellation is checked. +only after the final page receipt is written and cancellation is checked. Once +`COMMIT` succeeds there is no fallible cancellation check before the final +committed receipt is acknowledged. The existing one-page API becomes a one-element wrapper over the batch API so all callers share one correctness path. @@ -138,55 +147,37 @@ A successful commit advances progress to the final receipt cursor. A failed commit advances neither SQLite progress nor the sealed-source cursor. Recovery continues from the last committed page exactly as it does today. -### 4. Remove bulk-load mutation amplification +### 4. Preserve the ingestion-time mutation fence -The mutable staging schema no longer installs row-level `content_epoch` -triggers while source pages are being loaded. Those triggers currently turn -millions of row inserts into millions of additional updates of one hot row. +The existing mutation-detection triggers remain active from staging-database +creation through finalization. They are currently the independent authority +that catches an external connection changing derived rows and rewriting the +public integrity digest before finalization. Moving them to finalization would +allow self-attesting corruption because the final digest passes read the same +mutable staging database. -The epoch guard is still required while bounded finalization spans multiple -transactions. Therefore finalization starts with one atomic transition: +Batching amortizes transaction and journal costs without weakening this fence. +A future trigger optimization requires a separate durable mutation authority +active from database creation and a pre-finalization corruption journey that +covers derived rows and postings; it is not part of this slice. -1. acquire the writer transaction; -2. install the mutation-detection triggers; -3. capture the epoch; -4. persist the finalization cursor; -5. commit. +### 5. Keep the native index inventory unchanged -After that transition source pages are immutable, the builder rejects further -append operations, and any external SQLite mutation advances the epoch and is -refused by the next bounded finalization wake. Existing pre-finalization -integrity digests continue to reject self-attesting derived corruption. - -This preserves the mutation contract while removing the trigger cost from the -only bulk-load phase. - -### 5. Build secondary indexes after ingestion - -Secondary indexes needed only for serving and verification are omitted from the -initial staging schema. Their construction becomes an explicit, durable -finalization phase after all source pages are committed. - -Index construction must remain interruptible and resumable. A single unbounded -`CREATE INDEX` over the corpus is not acceptable. The implementation therefore -builds index shadow tables in bounded key-ordered slices with persisted cursors, -then performs a short atomic name swap. The final schema and query plans remain -the same as the current artifact contract. - -Primary keys and uniqueness constraints required to reject duplicate or -out-of-order input remain active during ingestion. Only redundant serving -indexes move to finalization. - -The first implementation checkpoint may land batching and trigger removal -before deferred indexes, but the five-minute acceptance is not claimed until -the complete design is measured. +All current native SQLite indexes remain online during ingestion in this +slice. SQLite cannot incrementally populate a native index through shadow +tables and atomically rename it into place, and a monolithic `CREATE INDEX` +would violate bounded cancellation without changing the artifact contract. +Deferred index construction therefore remains a separately measured design +decision rather than a claimed part of this optimization. ### 6. Live progress authority Every committed batch publishes one immutable in-memory progress snapshot tied -to the exact generation and sealed-source identity. The mounted registry owns -an `Arc` snapshot slot separate from the scheduler mutex, so dashboard reads do -not block behind a long reconcile. +to the exact generation, sealed-source identity, and mount epoch. The mounted +registry owns an `Arc` snapshot slot separate from the scheduler mutex, so +dashboard reads do not block behind a long reconcile. Every publish uses a +generation/mount-epoch compare-and-swap; a delayed superseded worker can neither +overwrite nor clear the current generation's snapshot. The snapshot contains: @@ -198,15 +189,18 @@ The snapshot contains: - completed and total sealed lexical byte span; - current batch size and last commit duration; - monotonic elapsed time for the current process; -- rolling throughput over committed bytes and files; +- rolling throughput over authenticated completed sealed lexical bytes, with + file rate retained as secondary evidence; - estimated remaining seconds when at least two samples establish a positive rate; - last-progress timestamp and an optional typed blocked reason. -Progress is exact at committed batch boundaries. It never reports staged or -prepared work as durable. The percentage is based on authenticated sealed byte -and file boundaries, not database size or an inferred chunk total. ETA is -explicitly an estimate and is absent when no truthful rate exists. +Progress is exact at committed batch boundaries. Phase-entry snapshots may +change the phase while retaining the previous durable counters, but staged or +prepared work is never reported as durable. Percentage and ETA use the same +authenticated completed sealed-byte numerator and total sealed-byte span; file +rate is display-only. ETA is absent until two positive process-local, +same-generation samples exist. The slot is cleared or replaced on generation supersession and survives no process restart. On restart, the first snapshot is reconstructed from the @@ -268,14 +262,14 @@ Hotpath totals. source cursor. - Cancellation after commit returns the committed durable progress; retry starts at the next page. +- Every parallel preparation task observes cancellation and is joined before + the reservation is released. - A crash during a batch leaves SQLite at the prior transaction boundary. - A corrupt or non-contiguous receipt refuses resume; it is never skipped. - A generation change cancels preparation, drops uncommitted pages, and cannot publish progress under the new generation identity. - A dashboard reader sees either the previous complete snapshot or the next complete snapshot; it never observes a partially updated struct. -- Index construction resumes from its persisted finalization cursor and does - not rebuild already verified slices. ## Alternatives considered @@ -309,26 +303,37 @@ the replay bounded and fast instead. - cancellation during preparation and during SQLite mutation leaves the source and builder at the pre-batch cursor; - cancellation after commit resumes at the next exact page; -- crash/reopen after each batch boundary yields the same final artifact digest; -- inter-wake mutation remains a typed corruption after finalization installs - epoch triggers; +- a subprocess killed after DML/receipt-before-commit reopens at the prior + cursor, while a kill after commit-before-source-ack replays idempotently from + the committed final cursor; +- inter-wake mutation remains a typed corruption under the always-active epoch + fence; +- pre-finalization self-attesting derived-row and posting corruption remains a + typed refusal; - parallel preparation produces the same ordered relational values as serial preparation; -- deferred index construction produces the existing required index inventory - and query plans; -- dashboard progress is generation-scoped, monotonic, nonblocking, and absent - when no mounted build exists. +- dashboard progress is generation/epoch-scoped, monotonic, nonblocking, and + absent when no mounted build exists; a delayed superseded publisher cannot + overwrite or clear the current slot. ### Performance tests 1. A focused builder benchmark compares one-page and bounded-batch ingestion on the same deterministic page set and records rows, commits, bytes written, and wall time. -2. A high-posting fixture proves per-row epoch updates are eliminated during - ingestion. +2. A maximum-token/ngram-expansion fixture proves active preparation plus all + retained prepared pages remain within the single 256 MiB reservation and + the prepared-write ceiling bounds commit latency. 3. The production 10,592-file isolated journey is rerun from a cold store with native graph disabled and the same resident-memory authority. +The production acceptance is driven by one benchmark manifest that pins the +sealed source digest and generation, expected artifact receipt/digest, binary +features and worker count, cold-artifact/OS-cache policy, exact timer events, +RSS sampler, expected queries, restart injection point, and Hotpath identity +set. The timer starts when canonical reconcile admits the sealed source and +stops only after the same generation owns a query-ready text artifact. + The production acceptance is: - text artifact ready in at most 300 seconds; @@ -338,7 +343,8 @@ The production acceptance is: - restart from a deliberately stopped intermediate batch resumes without replaying committed pages; - Code and Observatory show monotonic progress at one-second cadence; -- no dynamic Hotpath identities and feature-on/feature-off builds both pass. +- the runtime-reported Hotpath identity set stays constant as corpus size grows, + with feature-on and feature-off builds both passing. If the full journey remains above five minutes, the branch must report the measured residual phase and continue optimizing it. A partial speedup is not From 4d203813dcc8144f6f2f99fbcfd40bb5af324907 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 24 Aug 2026 23:40:42 +0000 Subject: [PATCH 04/69] chore(perf): configure hotpath mcp for repository --- .mcp.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..046a84a15e --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "hotpath": { + "type": "http", + "url": "http://127.0.0.1:6771/mcp" + } + } +} From 976ca2ef09799be7b7c543a220d330a3243ac515 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 24 Aug 2026 23:45:28 +0000 Subject: [PATCH 05/69] perf(index): batch verified lexical source pages --- .../src/production/lexical_page_source.rs | 913 +++++++++++++++++- .../src/production/mod.rs | 3 +- 2 files changed, 866 insertions(+), 50 deletions(-) diff --git a/crates/tracedecay-code-index/src/production/lexical_page_source.rs b/crates/tracedecay-code-index/src/production/lexical_page_source.rs index 1d9b121a77..7ce62459fa 100644 --- a/crates/tracedecay-code-index/src/production/lexical_page_source.rs +++ b/crates/tracedecay-code-index/src/production/lexical_page_source.rs @@ -639,6 +639,76 @@ pub enum VerifiedSealedLexicalPageReadV1 { Complete(VerifiedSealedLexicalSourceReceiptV1), } +/// Deterministic admission limits for one staged lexical-page batch. +/// +/// Both limits are required so a caller cannot accidentally turn the verified +/// source into an unbounded retained-page queue. `maximum_retained_bytes` +/// charges the batch vector slots and +/// [`VerifiedSealedLexicalPageV1::retained_owned_bytes`] for every page +/// staged in the batch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VerifiedSealedLexicalPageBatchBoundsV1 { + maximum_pages: usize, + maximum_retained_bytes: usize, + page_slot_bytes: usize, +} + +impl VerifiedSealedLexicalPageBatchBoundsV1 { + pub fn new( + maximum_pages: usize, + maximum_retained_bytes: usize, + ) -> Result { + if maximum_pages == 0 { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch count bound must be non-zero".to_owned(), + )); + } + if maximum_retained_bytes == 0 { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch retained-byte bound must be non-zero".to_owned(), + )); + } + let page_slot_bytes = maximum_pages + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch slot bound overflowed".to_owned(), + ) + })?; + if page_slot_bytes > maximum_retained_bytes { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch retained-byte bound cannot hold its page slots" + .to_owned(), + )); + } + Ok(Self { + maximum_pages, + maximum_retained_bytes, + page_slot_bytes, + }) + } + + pub fn maximum_pages(&self) -> usize { + self.maximum_pages + } + + pub fn maximum_retained_bytes(&self) -> usize { + self.maximum_retained_bytes + } + + fn page_slot_bytes(&self) -> usize { + self.page_slot_bytes + } +} + +/// Result of one all-or-nothing lexical-page batch admission attempt. +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] // staged pages dominate this terminal read result +pub enum VerifiedSealedLexicalPageBatchReadV1 { + Pages(Vec), + Complete(VerifiedSealedLexicalSourceReceiptV1), +} + struct PendingSealedLexicalPageV1 { chunks: Vec, page_bytes: usize, @@ -659,6 +729,15 @@ enum StagedSealedLexicalPageReadV1 { Complete(VerifiedSealedLexicalSourceReceiptV1), } +#[allow(clippy::large_enum_variant)] // staged pages dominate this private batch result +enum StagedSealedLexicalPageBatchReadV1 { + Pages { + pages: Vec, + cursor: VerifiedSealedLexicalCursorV1, + }, + Complete(VerifiedSealedLexicalSourceReceiptV1), +} + /// Seekable, bounded lexical projection source over a verified v5/v6 seal. /// /// Opening performs a streaming structural scan and verifies the exact raw @@ -672,6 +751,7 @@ pub struct VerifiedSealedLexicalPageSourceV1 { file_count: u64, first_file_offset: u64, files_end_offset: u64, + total_lexical_bytes: u64, maximum_file_bytes: u64, source_state_digest: ManifestDigest, format_revision: u32, @@ -706,11 +786,20 @@ impl VerifiedSealedLexicalPageSourceV1 { layout.state_digest.clone(), layout.first_file_offset, )?; + let total_lexical_bytes = layout + .files_end_offset + .checked_sub(layout.first_file_offset) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical files array has an invalid byte span".to_owned(), + ) + })?; Ok(Self { reader, file_count: layout.file_count, first_file_offset: layout.first_file_offset, files_end_offset: layout.files_end_offset, + total_lexical_bytes, maximum_file_bytes: layout.maximum_file_bytes, source_state_digest: layout.state_digest, format_revision: layout.format_revision, @@ -752,11 +841,20 @@ impl VerifiedSealedLexicalPageSourceV1 { layout.state_digest.clone(), layout.first_file_offset, )?; + let total_lexical_bytes = layout + .files_end_offset + .checked_sub(layout.first_file_offset) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical files array has an invalid byte span".to_owned(), + ) + })?; Ok(Self { reader, file_count: layout.file_count, first_file_offset: layout.first_file_offset, files_end_offset: layout.files_end_offset, + total_lexical_bytes, maximum_file_bytes: layout.maximum_file_bytes, source_state_digest: layout.state_digest, format_revision: layout.format_revision, @@ -864,6 +962,35 @@ impl VerifiedSealedLexicalPageSourceV1 { &self.cursor } + /// Number of authenticated file records in this sealed lexical source. + pub fn total_files(&self) -> u64 { + self.file_count + } + + /// Authenticated files-array byte span available to the lexical source. + pub fn total_lexical_bytes(&self) -> u64 { + self.total_lexical_bytes + } + + /// Fully completed file records at the durable source cursor. + pub fn completed_files(&self) -> u64 { + self.cursor.next_file_ordinal() + } + + /// Authenticated files-array bytes fully passed by the durable source + /// cursor. A partially consumed file counts only after its final chunk and + /// imports are committed, matching `completed_files`. + pub fn completed_lexical_bytes(&self) -> Result { + self.cursor + .next_file_offset + .checked_sub(self.first_file_offset) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical cursor precedes the files-array start".to_owned(), + ) + }) + } + /// Restore this source to its just-opened state so a consumer whose /// staging failed after pages were already accepted can replay the same /// sealed pages on the same instance instead of terminally blocking. @@ -918,7 +1045,8 @@ impl VerifiedSealedLexicalPageSourceV1 { control: &dyn CodeIndexExecutionControlV1, admit: impl FnOnce(&VerifiedSealedLexicalPageV1) -> Result<(), E>, ) -> Result, CodeIndexProductionErrorV1> { - match self.stage_next_page(control)? { + let cursor = self.cursor.clone(); + match self.stage_next_page_at(&cursor, control)? { StagedSealedLexicalPageReadV1::Page(staged) => { if let Err(error) = admit(&staged.page) { return Ok(Err(error)); @@ -933,12 +1061,115 @@ impl VerifiedSealedLexicalPageSourceV1 { } } - fn stage_next_page( + /// Stage a bounded ordered page batch and advance only after the caller + /// accepts the complete borrowed slice. The source cursor remains at its + /// pre-batch position on source or callback failure, so retrying emits the + /// same ordered page sequence. + pub fn next_page_batch_if( &mut self, control: &dyn CodeIndexExecutionControlV1, + bounds: VerifiedSealedLexicalPageBatchBoundsV1, + admit: impl FnOnce(&[VerifiedSealedLexicalPageV1]) -> Result<(), E>, + ) -> Result, CodeIndexProductionErrorV1> { + let staged = hotpath::measure_block!("code_index.lexical_source.batch_stage", { + (|| { + let mut pages = Vec::new(); + pages + .try_reserve_exact(bounds.maximum_pages()) + .map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed lexical page batch reservation failed: {error}" + )) + })?; + let retained_page_slots = pages + .capacity() + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch reservation overflowed".to_owned(), + ) + })?; + if retained_page_slots > bounds.maximum_retained_bytes() + || retained_page_slots < bounds.page_slot_bytes() + { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch reservation exceeds its retained-byte bound" + .to_owned(), + )); + } + + let mut working_cursor = self.cursor.clone(); + let mut retained_bytes = retained_page_slots; + let mut completion = None; + while pages.len() < bounds.maximum_pages() { + match self.stage_next_page_at(&working_cursor, control)? { + StagedSealedLexicalPageReadV1::Page(staged) => { + let next_retained_bytes = retained_bytes + .checked_add(staged.page.retained_owned_bytes()) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch retained bytes overflowed" + .to_owned(), + ) + })?; + if next_retained_bytes > bounds.maximum_retained_bytes() { + if pages.is_empty() { + return Err(CodeIndexProductionErrorV1::Contract( + "one sealed lexical page exceeds the batch retained-byte bound" + .to_owned(), + )); + } + break; + } + retained_bytes = next_retained_bytes; + working_cursor = staged.cursor; + pages.push(staged.page); + } + StagedSealedLexicalPageReadV1::Complete(receipt) => { + if pages.is_empty() { + completion = Some(receipt); + } + break; + } + } + } + + Ok(if let Some(receipt) = completion { + StagedSealedLexicalPageBatchReadV1::Complete(receipt) + } else { + StagedSealedLexicalPageBatchReadV1::Pages { + pages, + cursor: working_cursor, + } + }) + })() + })?; + + match staged { + StagedSealedLexicalPageBatchReadV1::Complete(receipt) => { + Ok(Ok(VerifiedSealedLexicalPageBatchReadV1::Complete(receipt))) + } + StagedSealedLexicalPageBatchReadV1::Pages { + pages, + cursor: working_cursor, + } => { + if let Err(error) = admit(&pages) { + return Ok(Err(error)); + } + crate::hotpath_observe::record_pages(working_cursor.next_page_ordinal()); + self.cursor = working_cursor; + Ok(Ok(VerifiedSealedLexicalPageBatchReadV1::Pages(pages))) + } + } + } + + fn stage_next_page_at( + &mut self, + previous_cursor: &VerifiedSealedLexicalCursorV1, + control: &dyn CodeIndexExecutionControlV1, ) -> Result { checkpoint(control)?; - let mut cursor = self.cursor.clone(); + let mut cursor = previous_cursor.clone(); let mut page_hasher = page_hasher(cursor.next_page_ordinal); let mut chunks = Vec::new(); let mut page_bytes = 0usize; @@ -987,14 +1218,17 @@ impl VerifiedSealedLexicalPageSourceV1 { .saturating_add(serialized.len()) > self.maximum_page_bytes) { - return self.commit_page(PendingSealedLexicalPageV1 { - chunks, - page_bytes, - imports, - import_bytes, - cursor, - page_hasher, - }); + return self.commit_page( + previous_cursor, + PendingSealedLexicalPageV1 { + chunks, + page_bytes, + imports, + import_bytes, + cursor, + page_hasher, + }, + ); } hash_record(&mut page_hasher, &serialized)?; cursor.cumulative_digest = advance_digest( @@ -1015,14 +1249,17 @@ impl VerifiedSealedLexicalPageSourceV1 { ) })?; if chunks.len() == self.maximum_page_chunks { - return self.commit_page(PendingSealedLexicalPageV1 { - chunks, - page_bytes, - imports, - import_bytes, - cursor, - page_hasher, - }); + return self.commit_page( + previous_cursor, + PendingSealedLexicalPageV1 { + chunks, + page_bytes, + imports, + import_bytes, + cursor, + page_hasher, + }, + ); } } let mut import_ordinal = usize::try_from(cursor.next_import_ordinal).map_err(|_| { @@ -1054,14 +1291,17 @@ impl VerifiedSealedLexicalPageSourceV1 { .saturating_add(serialized.len()) > self.maximum_page_bytes { - return self.commit_page(PendingSealedLexicalPageV1 { - chunks, - page_bytes, - imports, - import_bytes, - cursor, - page_hasher, - }); + return self.commit_page( + previous_cursor, + PendingSealedLexicalPageV1 { + chunks, + page_bytes, + imports, + import_bytes, + cursor, + page_hasher, + }, + ); } hash_import_record(&mut page_hasher, &serialized)?; cursor.cumulative_digest = @@ -1099,38 +1339,44 @@ impl VerifiedSealedLexicalPageSourceV1 { // Commit after an importing file so a later file cannot append a // chunk after bytes already hashed as import records. if !imports.is_empty() { - return self.commit_page(PendingSealedLexicalPageV1 { + return self.commit_page( + previous_cursor, + PendingSealedLexicalPageV1 { + chunks, + page_bytes, + imports, + import_bytes, + cursor, + page_hasher, + }, + ); + } + } + + if !chunks.is_empty() || !imports.is_empty() { + return self.commit_page( + previous_cursor, + PendingSealedLexicalPageV1 { chunks, page_bytes, imports, import_bytes, cursor, page_hasher, - }); - } - } - - if !chunks.is_empty() || !imports.is_empty() { - return self.commit_page(PendingSealedLexicalPageV1 { - chunks, - page_bytes, - imports, - import_bytes, - cursor, - page_hasher, - }); + }, + ); } Ok(StagedSealedLexicalPageReadV1::Complete( VerifiedSealedLexicalSourceReceiptV1 { source_state_digest: self.source_state_digest.clone(), format_revision: self.format_revision, - page_count: self.cursor.next_page_ordinal, - total_chunks: self.cursor.emitted_chunks, - total_payload_bytes: self.cursor.emitted_payload_bytes, - total_imports: self.cursor.emitted_imports, - import_payload_bytes: self.cursor.emitted_import_payload_bytes, - import_dictionary_digest: self.cursor.import_dictionary_digest.clone(), - cumulative_digest: self.cursor.cumulative_digest.clone(), + page_count: previous_cursor.next_page_ordinal, + total_chunks: previous_cursor.emitted_chunks, + total_payload_bytes: previous_cursor.emitted_payload_bytes, + total_imports: previous_cursor.emitted_imports, + import_payload_bytes: previous_cursor.emitted_import_payload_bytes, + import_dictionary_digest: previous_cursor.import_dictionary_digest.clone(), + cumulative_digest: previous_cursor.cumulative_digest.clone(), }, )) } @@ -1207,6 +1453,7 @@ impl VerifiedSealedLexicalPageSourceV1 { fn commit_page( &mut self, + previous_cursor: &VerifiedSealedLexicalCursorV1, pending: PendingSealedLexicalPageV1, ) -> Result { let PendingSealedLexicalPageV1 { @@ -1288,7 +1535,7 @@ impl VerifiedSealedLexicalPageSourceV1 { next_cursor: cursor.clone(), chunks, imports, - previous_cursor: self.cursor.clone(), + previous_cursor: previous_cursor.clone(), }; Ok(StagedSealedLexicalPageReadV1::Page( StagedSealedLexicalPageV1 { page, cursor }, @@ -1870,3 +2117,571 @@ fn digest_hasher(hasher: Sha256) -> Result usize { 1 }\n", + "pub fn retained_batch_page() -> &'static str { ", + "\"retained-batch-page-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\" }\n", + "pub fn final_batch_page() -> usize { 3 }\n", + ); + + #[derive(Default)] + struct TestPublicationStore; + + impl CodeIndexAtomicPublicationPort for TestPublicationStore { + fn load_active( + &self, + _scope: &CodeIndexGenerationScopeV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> + { + Ok(None) + } + + fn publish_atomically( + &mut self, + _scope: &CodeIndexGenerationScopeV1, + _expected_active_generation: Option<&tracedecay_domain::CodeGenerationId>, + _generation: Arc, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + Ok(()) + } + } + + #[derive(Default)] + struct ApplyingProjectionSink; + + impl CodeChunkProjectionSink for ApplyingProjectionSink { + fn project_changed_chunks( + &mut self, + request: &tracedecay_domain::ProjectionBatchRequestV1, + receipt_builder: ProjectionReceiptBuilderV1<'_>, + ) -> Result { + let mut decisions: Vec = request + .changes + .added_or_changed + .iter() + .map(|change| ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: change.current_digest.clone(), + operation: if change.prior_digest.is_some() { + tracedecay_domain::ProjectionOperationV1::Updated + } else { + tracedecay_domain::ProjectionOperationV1::Added + }, + outcome: tracedecay_domain::ProjectionOutcomeV1::Applied, + output_digest: Some( + change + .current_digest + .clone() + .expect("added or changed chunks have a digest"), + ), + }) + .collect(); + decisions.extend(request.changes.deleted.iter().map(|change| { + ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: None, + operation: tracedecay_domain::ProjectionOperationV1::Deleted, + outcome: tracedecay_domain::ProjectionOutcomeV1::Applied, + output_digest: None, + } + })); + decisions.extend(request.changes.reused.iter().map(|change| { + ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: change.current_digest.clone(), + operation: tracedecay_domain::ProjectionOperationV1::Reused, + outcome: tracedecay_domain::ProjectionOutcomeV1::Reused, + output_digest: None, + } + })); + receipt_builder + .build(&decisions) + .map_err(|error| ProjectionSinkErrorV1::Rejected(error.to_string())) + } + } + + struct CancelDuringStaging { + checks: AtomicUsize, + } + + impl CancelDuringStaging { + fn new() -> Self { + Self { + checks: AtomicUsize::new(0), + } + } + } + + impl CodeIndexExecutionControlV1 for CancelDuringStaging { + fn is_cancelled(&self) -> bool { + self.checks.fetch_add(1, Ordering::AcqRel) >= 3 + } + + fn is_deadline_exceeded(&self) -> bool { + false + } + } + + struct ActiveControl; + + impl CodeIndexExecutionControlV1 for ActiveControl { + fn is_cancelled(&self) -> bool { + false + } + + fn is_deadline_exceeded(&self) -> bool { + false + } + } + + struct SealedSourceFixture { + sealed: Vec, + state_digest: ManifestDigest, + } + + impl SealedSourceFixture { + fn open(&self) -> VerifiedSealedLexicalPageSourceV1>> { + VerifiedSealedLexicalPageSourceV1::open( + Cursor::new(self.sealed.clone()), + u64::try_from(self.sealed.len()).expect("sealed fixture length fits u64"), + self.state_digest.clone(), + 1, + 1024 * 1024, + &ActiveControl, + ) + .expect("real sealed fixture source opens") + } + } + + #[derive(Debug, PartialEq, Eq)] + struct OnePageExpectation { + page_ordinal: u64, + chunk_count: u64, + payload_bytes: u64, + import_count: u64, + import_payload_bytes: u64, + page_digest: String, + next_cursor: Vec, + retained_owned_bytes: usize, + } + + fn fixture() -> SealedSourceFixture { + let source = BATCH_FIXTURE_SOURCE.as_bytes(); + let file = SanitizedCodeFileV1 { + file_occurrence_id: FileOccurrenceId::new("file.lexical-page-batch") + .expect("fixture file occurrence ID"), + logical_path: "src/batch_fixture.rs".to_owned(), + language: Some(LanguageId::new("rust").expect("fixture language ID")), + content_digest: content_digest(source), + disposition: SnapshotFileDispositionV1::Present, + }; + let snapshot = SanitizedCodeSnapshotV1 { + repository: RepositoryId::new("repository.lexical-page-batch") + .expect("fixture repository ID"), + worktree: None, + reference: None, + source_revision: None, + sanitizer_revision: SanitizerRevision::new("sanitizer.lexical-page-batch") + .expect("fixture sanitizer revision"), + sanitization_receipts: vec![ + SanitizationReceiptId::new("receipt.lexical-page-batch") + .expect("fixture sanitization receipt"), + ], + content_identity: content_digest(source), + captured_at: UtcMicros(1_000_000), + files: vec![file.clone()], + }; + let request = CodeIndexBuildRequestV1 { + snapshot, + captured_files: vec![CodeIndexCapturedFileV1 { + file_occurrence_id: file.file_occurrence_id, + sanitized_bytes: source.to_vec(), + sensitivity_level: SensitivityLevelV1::Public, + }], + changed_files: BTreeSet::new(), + invalidations: BTreeSet::new(), + ignored_source_admissions: Vec::new(), + repository_parse_identity: CodeIndexRepositoryParseIdentityV1 { + tree: None, + dirty: RepositoryDirtyStateV1::Dirty, + }, + sealed_at: UtcMicros(1_100_000), + target_projection_key: ProjectionKeyV1 { + kind: ProjectionKindV1::Lexical, + schema_revision: "lexical.v1".to_owned(), + profile_digest: ManifestDigest::new(format!("sha256:{}", "e".repeat(64))) + .expect("fixture projection profile digest"), + }, + }; + let mut owner = CodeIndexProductionOwnerV1::new( + CodeIndexProductionConfigV1 { + project_id: ProjectId::new("project.lexical-page-batch") + .expect("fixture project ID"), + repository: RepositoryId::new("repository.lexical-page-batch") + .expect("fixture repository ID"), + sanitizer_revision: SanitizerRevision::new("sanitizer.lexical-page-batch") + .expect("fixture sanitizer revision"), + policy_revision: PolicyRevisionId::new("policy.lexical-page-batch") + .expect("fixture policy revision"), + chunker_revision: ChunkerRevision::new("chunker.lexical-page-batch") + .expect("fixture chunker revision"), + privacy_domain: PrivacyDomainId::new("privacy.lexical-page-batch") + .expect("fixture privacy domain"), + privacy_key_epoch: 7, + max_snapshot_age_micros: None, + }, + TestPublicationStore, + ApplyingProjectionSink, + ) + .expect("fixture production owner opens"); + let generation = owner + .build_and_publish(request, &ActiveControl) + .expect("fixture generation publishes"); + let sealed = generation + .encode_sealed() + .expect("fixture generation seals"); + let envelope: serde_json::Value = + serde_json::from_slice(&sealed).expect("fixture sealed envelope decodes"); + let state_digest = ManifestDigest::new( + envelope["state_digest"] + .as_str() + .expect("fixture sealed state digest"), + ) + .expect("fixture state digest is canonical"); + SealedSourceFixture { + sealed, + state_digest, + } + } + + fn one_page_expectations(fixture: &SealedSourceFixture) -> Vec { + let mut source = fixture.open(); + let mut expectations = Vec::new(); + loop { + match source + .next_page(&ActiveControl) + .expect("fixture one-page read") + { + VerifiedSealedLexicalPageReadV1::Page(page) => { + expectations.push(expectation(&page)); + } + VerifiedSealedLexicalPageReadV1::Complete(receipt) => { + receipt + .verify_completion(Some(source.cursor())) + .expect("fixture one-page receipt verifies"); + return expectations; + } + } + } + } + + fn expectation(page: &VerifiedSealedLexicalPageV1) -> OnePageExpectation { + OnePageExpectation { + page_ordinal: page.page_ordinal(), + chunk_count: page.chunk_count(), + payload_bytes: page.payload_bytes(), + import_count: page.import_count(), + import_payload_bytes: page.import_payload_bytes(), + page_digest: page.page_digest().as_str().to_owned(), + next_cursor: page + .next_cursor() + .persisted_bytes() + .expect("one-page cursor persists"), + retained_owned_bytes: page.retained_owned_bytes(), + } + } + + fn assert_page_matches(page: &VerifiedSealedLexicalPageV1, expected: &OnePageExpectation) { + assert_eq!(page.page_ordinal(), expected.page_ordinal); + assert_eq!(page.chunk_count(), expected.chunk_count); + assert_eq!(page.payload_bytes(), expected.payload_bytes); + assert_eq!(page.import_count(), expected.import_count); + assert_eq!(page.import_payload_bytes(), expected.import_payload_bytes); + assert_eq!(page.page_digest().as_str(), expected.page_digest.as_str()); + assert_eq!( + page.next_cursor() + .persisted_bytes() + .expect("batch page cursor persists"), + expected.next_cursor, + ); + } + + fn bounds_for(expected: &[OnePageExpectation]) -> VerifiedSealedLexicalPageBatchBoundsV1 { + let page_slots = std::mem::size_of::() + .checked_mul(expected.len()) + .expect("fixture page-slot bytes do not overflow"); + let retained_bytes = expected.iter().fold(page_slots, |bytes, page| { + bytes + .checked_add(page.retained_owned_bytes) + .expect("fixture retained bytes do not overflow") + }); + VerifiedSealedLexicalPageBatchBoundsV1::new(expected.len(), retained_bytes) + .expect("fixture batch bounds are retainable") + } + + fn pages(read: VerifiedSealedLexicalPageBatchReadV1) -> Vec { + match read { + VerifiedSealedLexicalPageBatchReadV1::Pages(pages) => pages, + VerifiedSealedLexicalPageBatchReadV1::Complete(_) => { + panic!("fixture must stage lexical pages") + } + } + } + + #[test] + fn batch_bounds_refuse_limits_that_cannot_retain_a_bounded_page_batch() { + let page_slot_bytes = std::mem::size_of::(); + for (maximum_pages, maximum_retained_bytes) in + [(0, 1), (1, 0), (1, page_slot_bytes.saturating_sub(1))] + { + let error = + VerifiedSealedLexicalPageBatchBoundsV1::new(maximum_pages, maximum_retained_bytes) + .expect_err("an unbounded or unretainable batch must be refused"); + assert!(matches!(error, CodeIndexProductionErrorV1::Contract(_))); + } + } + + #[test] + fn rejected_batch_keeps_the_exact_cursor_and_retries_the_first_one_page_value() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + assert!( + expected.len() >= 2, + "fixture must provide a multi-page source" + ); + let mut source = fixture.open(); + let cursor_before = source + .cursor() + .persisted_bytes() + .expect("initial cursor persists"); + let rejected = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected[..2]), |pages| { + assert_eq!(pages.len(), 2, "fixture stages a full two-page batch"); + Err::<(), _>("builder rejects the complete batch") + }) + .expect("source stages the rejected batch"); + assert_eq!( + rejected.expect_err("callback refusal must be surfaced"), + "builder rejects the complete batch" + ); + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("rejected cursor persists"), + cursor_before, + ); + + let retried = match source.next_page(&ActiveControl).expect("one-page retry") { + VerifiedSealedLexicalPageReadV1::Page(page) => page, + VerifiedSealedLexicalPageReadV1::Complete(_) => panic!("fixture must retain pages"), + }; + assert_page_matches(&retried, &expected[0]); + } + + #[test] + fn count_bound_returns_the_first_two_one_page_values_in_order() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + assert!( + expected.len() >= 2, + "fixture must provide a multi-page source" + ); + let mut source = fixture.open(); + let batch = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected[..2]), |pages| { + assert_eq!(pages.len(), 2); + Ok::<(), ()>(()) + }) + .expect("source stages a count-bounded batch") + .expect("callback accepts the count-bounded batch"); + let batch = pages(batch); + assert_eq!(batch.len(), 2); + assert_page_matches(&batch[0], &expected[0]); + assert_page_matches(&batch[1], &expected[1]); + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("batch cursor persists"), + expected[1].next_cursor, + ); + } + + #[test] + fn retained_byte_bound_stops_before_the_next_larger_one_page_value() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + let (start, first, second) = expected + .windows(2) + .enumerate() + .find_map(|(index, pair)| { + (pair[0].retained_owned_bytes < pair[1].retained_owned_bytes) + .then_some((index, &pair[0], &pair[1])) + }) + .expect("fixture has an increasing one-page retained-byte boundary"); + let mut source = fixture.open(); + for _ in 0..start { + let _ = source + .next_page(&ActiveControl) + .expect("advance to retained boundary"); + } + let page_slots = std::mem::size_of::() + .checked_mul(2) + .expect("fixture page-slot bytes do not overflow"); + let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new( + 2, + page_slots + .checked_add(first.retained_owned_bytes) + .expect("fixture retained bound does not overflow"), + ) + .expect("first page fits the retained-byte bound"); + let batch = source + .next_page_batch_if(&ActiveControl, bounds, |pages| { + assert_eq!(pages.len(), 1, "larger next page must stay unstaged"); + Ok::<(), ()>(()) + }) + .expect("source stages the retained-byte-bounded batch") + .expect("callback accepts the retained-byte-bounded batch"); + let batch = pages(batch); + assert_eq!(batch.len(), 1); + assert_page_matches(&batch[0], first); + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("retained-byte cursor persists"), + first.next_cursor, + ); + + let next = match source + .next_page(&ActiveControl) + .expect("read byte-stopped page") + { + VerifiedSealedLexicalPageReadV1::Page(page) => page, + VerifiedSealedLexicalPageReadV1::Complete(_) => panic!("fixture must retain next page"), + }; + assert_page_matches(&next, second); + } + + #[test] + fn completion_follows_the_last_accepted_batch_without_an_empty_callback() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + assert!(!expected.is_empty(), "fixture must provide lexical pages"); + let mut source = fixture.open(); + let accepted = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected), |pages| { + assert_eq!(pages.len(), expected.len()); + Ok::<(), ()>(()) + }) + .expect("source stages the final batch") + .expect("callback accepts the final batch"); + let accepted = pages(accepted); + assert_eq!(accepted.len(), expected.len()); + for (page, expected) in accepted.iter().zip(&expected) { + assert_page_matches(page, expected); + } + + let mut callback_called = false; + let complete = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected), |_| { + callback_called = true; + Ok::<(), ()>(()) + }) + .expect("completed source stays readable") + .expect("completion has no callback error"); + let VerifiedSealedLexicalPageBatchReadV1::Complete(receipt) = complete else { + panic!("completion follows the last accepted batch") + }; + assert!( + !callback_called, + "completion must not invoke an empty callback" + ); + receipt + .verify_completion(Some(source.cursor())) + .expect("completed receipt matches accepted cursor"); + } + + #[test] + fn cancellation_during_staging_keeps_the_exact_pre_batch_cursor() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + assert!( + expected.len() >= 2, + "fixture must provide a multi-page source" + ); + let mut source = fixture.open(); + let cursor_before = source + .cursor() + .persisted_bytes() + .expect("initial cursor persists"); + let control = CancelDuringStaging::new(); + let mut callback_called = false; + let error = source + .next_page_batch_if(&control, bounds_for(&expected[..2]), |_| { + callback_called = true; + Ok::<(), ()>(()) + }) + .expect_err("cancellation must interrupt batch staging"); + assert!(matches!( + error, + CodeIndexProductionErrorV1::Interrupted(CodeIndexInterruptionV1::Cancelled) + )); + assert!( + control.checks.load(Ordering::Acquire) > 1, + "cancellation must be checked during source staging" + ); + assert!( + !callback_called, + "cancelled staging must not invoke the callback" + ); + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("cancelled cursor persists"), + cursor_before, + ); + } +} diff --git a/crates/tracedecay-code-index/src/production/mod.rs b/crates/tracedecay-code-index/src/production/mod.rs index 8cb1b179cf..b4baddc098 100644 --- a/crates/tracedecay-code-index/src/production/mod.rs +++ b/crates/tracedecay-code-index/src/production/mod.rs @@ -76,7 +76,8 @@ mod generation_statistics; pub use generation_statistics::CodeIndexGenerationStatisticsV1; mod lexical_page_source; pub use lexical_page_source::{ - VerifiedSealedLexicalCursorV1, VerifiedSealedLexicalPageReadV1, + VerifiedSealedLexicalCursorV1, VerifiedSealedLexicalPageBatchBoundsV1, + VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, VerifiedSealedLexicalSourceReceiptV1, }; From aad7637f1f8f0678359b807ca78db3b87a86e7b1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 00:09:11 +0000 Subject: [PATCH 06/69] perf(index): bulk commit lexical artifact pages --- Cargo.lock | 1 + crates/tracedecay-query/Cargo.toml | 5 + .../benches/code_lexical_catchup.rs | 471 ++++++ .../tracedecay-query/src/retrieval/lexical.rs | 8 +- .../src/retrieval/lexical/projection.rs | 10 +- .../retrieval/lexical/projection/artifact.rs | 31 +- .../lexical/projection/artifact/builder.rs | 1369 +++++++++++++---- .../lexical/projection/artifact/postings.rs | 39 +- .../lexical/projection/artifact/prepared.rs | 589 +++++++ .../lexical/projection/artifact/reader.rs | 50 +- .../candidate_producers.rs | 364 ++++- 11 files changed, 2557 insertions(+), 380 deletions(-) create mode 100644 crates/tracedecay-query/benches/code_lexical_catchup.rs create mode 100644 crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs diff --git a/Cargo.lock b/Cargo.lock index 611717ea8c..80154a2b28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6337,6 +6337,7 @@ dependencies = [ "hex", "hmac 0.13.0", "hotpath", + "rayon", "roaring", "rusqlite", "serde", diff --git a/crates/tracedecay-query/Cargo.toml b/crates/tracedecay-query/Cargo.toml index 8efb2a9767..42db0beafc 100644 --- a/crates/tracedecay-query/Cargo.toml +++ b/crates/tracedecay-query/Cargo.toml @@ -13,6 +13,7 @@ fst = { version = "=0.4.7", default-features = false, features = ["levenshtein"] hex = "0.4" hmac = "0.13.0" hotpath.workspace = true +rayon = "1" roaring = { version = "=0.11.4", default-features = false } rusqlite.workspace = true serde = { version = "1", features = ["derive"] } @@ -75,3 +76,7 @@ tempfile = "3" tracedecay-code-extraction = { path = "../tracedecay-code-extraction", features = ["lite"] } tracedecay-code-index = { path = "../tracedecay-code-index", default-features = false, features = ["lite", "test-helpers"] } tracedecay-graph-db = { path = "../tracedecay-graph-db", version = "0.1.0", features = ["test-helpers"] } + +[[bench]] +name = "code_lexical_catchup" +harness = false diff --git a/crates/tracedecay-query/benches/code_lexical_catchup.rs b/crates/tracedecay-query/benches/code_lexical_catchup.rs new file mode 100644 index 0000000000..d1a9f71bb3 --- /dev/null +++ b/crates/tracedecay-query/benches/code_lexical_catchup.rs @@ -0,0 +1,471 @@ +//! Deterministic, operator-data-free comparison of lexical artifact ingestion +//! transaction shapes. This target intentionally owns its fixture because +//! query's production fixture helpers are test-private. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::io::Cursor; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use serde::Serialize; +use tracedecay_code_index::chunks::content_digest; +use tracedecay_code_index::production::{ + CodeIndexAtomicPublicationPort, CodeIndexBuildRequestV1, CodeIndexCapturedFileV1, + CodeIndexExecutionControlV1, CodeIndexGenerationScopeV1, CodeIndexProductionConfigV1, + CodeIndexProductionOwnerV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, + CodeIndexRepositoryParseIdentityV1, VerifiedSealedLexicalPageReadV1, + VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, + VerifiedSealedLexicalSourceReceiptV1, +}; +use tracedecay_code_index::projection::{ + ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, + ProjectionSinkErrorV1, ProjectionSinkReceiptV1, +}; +use tracedecay_domain::{ + ChunkerRevision, CodeGenerationId, ComponentRevision, FileOccurrenceId, + FreshnessCompatibilityV1, ManifestDigest, PolicyRevisionId, PrivacyDomainId, ProjectId, + ProjectionBatchRequestV1, ProjectionKeyV1, ProjectionKindV1, ProjectionOperationV1, + ProjectionOutcomeV1, RepositoryDirtyStateV1, RepositoryId, SanitizationReceiptId, + SanitizedCodeFileV1, SanitizedCodeSnapshotV1, SanitizerRevision, ScoreDomainId, + SensitivityLevelV1, SnapshotFileDispositionV1, SourceFreshness, SourceInstanceKey, + SourceNamespace, UtcMicros, +}; +use tracedecay_query::retrieval::lexical::{ + CodeLexicalArtifactBuilderV1, CodeLexicalArtifactFinalizationStepV1, + CodeLexicalProjectionMetadataV1, VerifiedCodeLexicalArtifactV1, +}; + +const FIXTURE_FILE_COUNT: usize = 48; +const BATCH_PAGE_LIMIT: usize = 16; + +struct ActiveControl; + +impl CodeIndexExecutionControlV1 for ActiveControl { + fn is_cancelled(&self) -> bool { + false + } + + fn is_deadline_exceeded(&self) -> bool { + false + } +} + +#[derive(Default)] +struct PublicationStore { + active: Arc>>>, +} + +impl CodeIndexAtomicPublicationPort for PublicationStore { + fn load_active( + &self, + scope: &CodeIndexGenerationScopeV1, + ) -> Result, CodeIndexPublicationStoreErrorV1> { + Ok(self + .active + .lock() + .expect("benchmark publication lock") + .get(scope) + .map(|generation| generation.as_ref().clone())) + } + + fn publish_atomically( + &mut self, + scope: &CodeIndexGenerationScopeV1, + expected_active_generation: Option<&CodeGenerationId>, + generation: Arc, + ) -> Result<(), CodeIndexPublicationStoreErrorV1> { + let mut active = self.active.lock().expect("benchmark publication lock"); + if active + .get(scope) + .map(|current| current.manifest().generation_id.clone()) + .as_ref() + != expected_active_generation + { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + active.insert(scope.clone(), generation); + Ok(()) + } +} + +struct ProjectionSink; + +impl CodeChunkProjectionSink for ProjectionSink { + fn project_changed_chunks( + &mut self, + request: &ProjectionBatchRequestV1, + receipt_builder: ProjectionReceiptBuilderV1<'_>, + ) -> Result { + let mut decisions = request + .changes + .added_or_changed + .iter() + .map(|change| ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: change.current_digest.clone(), + operation: if change.prior_digest.is_some() { + ProjectionOperationV1::Updated + } else { + ProjectionOperationV1::Added + }, + outcome: ProjectionOutcomeV1::Applied, + output_digest: change.current_digest.clone(), + }) + .collect::>(); + decisions.extend( + request + .changes + .deleted + .iter() + .map(|change| ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: None, + operation: ProjectionOperationV1::Deleted, + outcome: ProjectionOutcomeV1::Applied, + output_digest: None, + }), + ); + decisions.extend( + request + .changes + .reused + .iter() + .map(|change| ChunkProjectionDecisionV1 { + chunk_id: change.chunk_id.clone(), + prior_chunk_digest: change.prior_digest.clone(), + current_chunk_digest: change.current_digest.clone(), + operation: ProjectionOperationV1::Reused, + outcome: ProjectionOutcomeV1::Reused, + output_digest: None, + }), + ); + receipt_builder + .build(&decisions) + .map_err(|error| ProjectionSinkErrorV1::Rejected(error.to_string())) + } +} + +struct Fixture { + metadata: CodeLexicalProjectionMetadataV1, + pages: Vec, + source_receipt: VerifiedSealedLexicalSourceReceiptV1, +} + +#[derive(Clone, Copy)] +enum IngestionMode { + OnePage, + BoundedBatch, +} + +impl IngestionMode { + const fn name(self) -> &'static str { + match self { + Self::OnePage => "one_page", + Self::BoundedBatch => "bounded_batch", + } + } +} + +#[derive(Serialize)] +struct RunReport { + mode: &'static str, + ingest_wall_ns: u64, + end_to_end_wall_ns: u64, + committed_pages: u64, + committed_chunks: u64, + committed_payload_bytes: u64, + sqlite_ingestion_commits: u64, + artifact_bytes: u64, + artifact_digest: String, + source_cumulative_digest: String, +} + +struct RunResult { + report: RunReport, + receipt: VerifiedCodeLexicalArtifactV1, +} + +#[derive(Serialize)] +struct ComparisonReport { + fixture_pages: usize, + batch_page_limit: usize, + one_page: RunReport, + bounded_batch: RunReport, + final_receipt_equal: bool, + artifact_digest_equal: bool, + source_cumulative_digest_equal: bool, +} + +fn main() { + let fixture = build_fixture(); + let one_page = run(&fixture, IngestionMode::OnePage); + let bounded_batch = run(&fixture, IngestionMode::BoundedBatch); + + let final_receipt_equal = one_page.receipt == bounded_batch.receipt; + let artifact_digest_equal = + one_page.receipt.artifact_digest() == bounded_batch.receipt.artifact_digest(); + let source_cumulative_digest_equal = one_page.receipt.source_cumulative_digest() + == bounded_batch.receipt.source_cumulative_digest(); + let report = ComparisonReport { + fixture_pages: fixture.pages.len(), + batch_page_limit: BATCH_PAGE_LIMIT, + one_page: one_page.report, + bounded_batch: bounded_batch.report, + final_receipt_equal, + artifact_digest_equal, + source_cumulative_digest_equal, + }; + + println!( + "{}", + serde_json::to_string_pretty(&report).expect("serialize benchmark report") + ); + assert!( + final_receipt_equal && artifact_digest_equal && source_cumulative_digest_equal, + "the compared ingestion paths must produce the exact same final receipt and digests" + ); +} + +fn build_fixture() -> Fixture { + let repository = id::("repository.catchup-benchmark"); + let sanitizer_revision = id::("sanitizer.catchup-benchmark.v1"); + let sources = (0..FIXTURE_FILE_COUNT) + .map(|ordinal| { + let file_id = format!("file.catchup.{ordinal:03}"); + let logical_path = format!("src/catchup_{ordinal:03}.ts"); + let source = format!( + "import type {{ Widget }} from \"widget-kit\";\nexport function render_{ordinal:03}(value: Widget) {{ return value; }}\n" + ) + .into_bytes(); + let file = SanitizedCodeFileV1 { + file_occurrence_id: id::(&file_id), + logical_path, + language: Some(id("typescript")), + content_digest: content_digest(&source), + disposition: SnapshotFileDispositionV1::Present, + }; + (file, source) + }) + .collect::>(); + let snapshot = SanitizedCodeSnapshotV1 { + repository: repository.clone(), + worktree: None, + reference: None, + source_revision: None, + sanitizer_revision: sanitizer_revision.clone(), + sanitization_receipts: vec![id::("receipt.catchup-benchmark")], + content_identity: content_digest(&sources[0].1), + captured_at: UtcMicros(1_000_000), + files: sources.iter().map(|(file, _)| file.clone()).collect(), + }; + let request = CodeIndexBuildRequestV1 { + snapshot, + captured_files: sources + .iter() + .map(|(file, source)| CodeIndexCapturedFileV1 { + file_occurrence_id: file.file_occurrence_id.clone(), + sanitized_bytes: source.clone(), + sensitivity_level: SensitivityLevelV1::Public, + }) + .collect(), + changed_files: sources + .iter() + .map(|(file, _)| file.logical_path.clone()) + .collect::>(), + invalidations: BTreeSet::new(), + ignored_source_admissions: Vec::new(), + repository_parse_identity: CodeIndexRepositoryParseIdentityV1 { + tree: None, + dirty: RepositoryDirtyStateV1::Dirty, + }, + sealed_at: UtcMicros(1_100_000), + target_projection_key: ProjectionKeyV1 { + kind: ProjectionKindV1::Lexical, + schema_revision: "lexical.v1".to_owned(), + profile_digest: digest_id('e'), + }, + }; + let config = CodeIndexProductionConfigV1 { + project_id: id::("project.catchup-benchmark"), + repository: repository.clone(), + sanitizer_revision, + policy_revision: id::("policy.catchup-benchmark.v1"), + chunker_revision: id::("chunker.catchup-benchmark.v1"), + privacy_domain: id::("privacy.catchup-benchmark"), + privacy_key_epoch: 1, + max_snapshot_age_micros: None, + }; + let control = ActiveControl; + let mut owner = + CodeIndexProductionOwnerV1::new(config, PublicationStore::default(), ProjectionSink) + .expect("build deterministic production fixture owner"); + let generation = owner + .build_and_publish(request, &control) + .expect("build deterministic sealed generation"); + let sealed = generation + .encode_sealed() + .expect("encode deterministic sealed generation"); + let sealed_len = u64::try_from(sealed.len()).expect("sealed generation length"); + let envelope: serde_json::Value = + serde_json::from_slice(&sealed).expect("decode sealed generation envelope"); + let state_digest = id::( + envelope["state_digest"] + .as_str() + .expect("sealed generation state digest"), + ); + let metadata = CodeLexicalProjectionMetadataV1 { + generation: generation.manifest().generation_id.clone(), + repository_id: Some(repository), + logical_paths: generation + .snapshot() + .files + .iter() + .map(|file| (file.file_occurrence_id.clone(), file.logical_path.clone())) + .collect(), + freshness: freshness(), + exact_retriever_revision: id::("retriever.exact.catchup-benchmark.v1"), + lexical_retriever_revision: id::( + "retriever.lexical.catchup-benchmark.v1", + ), + exact_score_domain: id::("score.exact.catchup-benchmark.v1"), + }; + let mut source = VerifiedSealedLexicalPageSourceV1::open( + Cursor::new(sealed), + sealed_len, + state_digest, + 1, + 1024 * 1024, + &control, + ) + .expect("open verified lexical page source"); + let mut pages = Vec::new(); + let source_receipt = loop { + match source + .next_page(&control) + .expect("read verified lexical page") + { + VerifiedSealedLexicalPageReadV1::Page(page) => pages.push(page), + VerifiedSealedLexicalPageReadV1::Complete(receipt) => break receipt, + } + }; + assert!( + pages.len() > BATCH_PAGE_LIMIT, + "fixture must provide more pages than one bounded batch" + ); + Fixture { + metadata, + pages, + source_receipt, + } +} + +fn run(fixture: &Fixture, mode: IngestionMode) -> RunResult { + let directory = tempfile::tempdir().expect("create benchmark artifact directory"); + let artifact_path = directory.path().join(format!("{}.sqlite", mode.name())); + let control = ActiveControl; + let mut builder = + CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata.clone()) + .expect("create isolated benchmark artifact"); + let started = Instant::now(); + let (progress, sqlite_ingestion_commits) = match mode { + IngestionMode::OnePage => { + let mut progress = builder.progress().expect("read initial artifact progress"); + for page in &fixture.pages { + progress = builder + .append_page(page, &control) + .expect("append deterministic benchmark page"); + } + ( + progress, + u64::try_from(fixture.pages.len()).expect("page count fits u64"), + ) + } + IngestionMode::BoundedBatch => { + let mut progress = builder.progress().expect("read initial artifact progress"); + let mut commits = 0_u64; + for pages in fixture.pages.chunks(BATCH_PAGE_LIMIT) { + progress = builder + .append_pages(pages, &control) + .expect("append deterministic benchmark page batch"); + commits = commits.checked_add(1).expect("commit count fits u64"); + } + (progress, commits) + } + }; + let ingest_wall_ns = elapsed_ns(started.elapsed()); + let receipt = finalize(&mut builder, &fixture.source_receipt, &control); + let end_to_end_wall_ns = elapsed_ns(started.elapsed()); + assert_eq!( + progress.next_page_ordinal, + u64::try_from(fixture.pages.len()).expect("page count fits u64"), + "ingestion must durably commit every verified fixture page" + ); + RunResult { + report: RunReport { + mode: mode.name(), + ingest_wall_ns, + end_to_end_wall_ns, + committed_pages: progress.next_page_ordinal, + committed_chunks: progress.completed_chunks, + committed_payload_bytes: progress.completed_payload_bytes, + sqlite_ingestion_commits, + artifact_bytes: receipt.file_size_bytes(), + artifact_digest: receipt.artifact_digest().as_str().to_owned(), + source_cumulative_digest: receipt.source_cumulative_digest().as_str().to_owned(), + }, + receipt, + } +} + +fn finalize( + builder: &mut CodeLexicalArtifactBuilderV1, + source_receipt: &VerifiedSealedLexicalSourceReceiptV1, + control: &dyn CodeIndexExecutionControlV1, +) -> VerifiedCodeLexicalArtifactV1 { + loop { + match builder + .advance_finalization(source_receipt, 4_096, control) + .expect("finalize deterministic benchmark artifact") + { + CodeLexicalArtifactFinalizationStepV1::Pending { .. } => {} + CodeLexicalArtifactFinalizationStepV1::Ready(receipt) => return *receipt, + } + } +} + +fn freshness() -> SourceFreshness { + SourceFreshness { + source_namespace: id::("ns.code.catchup-benchmark"), + source_instance: id::("instance.catchup-benchmark"), + source_watermark: Some(7), + projection_watermark: Some(7), + observed_at: UtcMicros(7), + source_generation: Some(1), + generation_lag: Some(0), + compatibility: FreshnessCompatibilityV1::Current, + policy_revision: id("policy.catchup-benchmark.v1"), + } +} + +fn id(value: &str) -> T +where + T: TryFrom, + >::Error: fmt::Debug, +{ + T::try_from(value.to_owned()).expect("valid deterministic benchmark identity") +} + +fn digest_id(byte: char) -> T +where + T: TryFrom, + >::Error: fmt::Debug, +{ + id(&format!("sha256:{}", byte.to_string().repeat(64))) +} + +fn elapsed_ns(duration: std::time::Duration) -> u64 { + u64::try_from(duration.as_nanos()).expect("benchmark wall time fits u64") +} diff --git a/crates/tracedecay-query/src/retrieval/lexical.rs b/crates/tracedecay-query/src/retrieval/lexical.rs index a08ccf5cef..df7874f21b 100644 --- a/crates/tracedecay-query/src/retrieval/lexical.rs +++ b/crates/tracedecay-query/src/retrieval/lexical.rs @@ -29,13 +29,15 @@ pub use self::projection::{ CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeExactLexicalArtifactReaderV1, - CodeExactProjectionAdapterV1, CodeLexicalArtifactBuildProgressV1, CodeLexicalArtifactBuilderV1, - CodeLexicalArtifactErrorV1, CodeLexicalArtifactFinalizationStepV1, + CodeExactProjectionAdapterV1, CodeLexicalArtifactBatchLimitV1, + CodeLexicalArtifactBuildProgressV1, CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, + CodeLexicalArtifactFinalizationPhaseV1, CodeLexicalArtifactFinalizationStepV1, CodeLexicalArtifactOccurrenceV1, CodeLexicalArtifactReaderV1, CodeLexicalArtifactSectionDigestV1, CodeLexicalImportMembershipWitnessV1, CodeLexicalProjectionAdapterV1, CodeLexicalProjectionBuildStepV1, CodeLexicalProjectionBuildV1, CodeLexicalProjectionMetadataV1, LEXICAL_PROJECTION_BUILD_DEADLINE_MICROS_V1, - VerifiedCodeLexicalArtifactV1, lexical_projection_build_deadline_micros, + PreparedCodeLexicalArtifactPageV1, VerifiedCodeLexicalArtifactV1, + lexical_projection_build_deadline_micros, }; /// Wording the lexical lane uses when a port-emitted batch fails the shared diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection.rs b/crates/tracedecay-query/src/retrieval/lexical/projection.rs index f338a74872..8f9870ffe0 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection.rs @@ -31,10 +31,12 @@ pub use artifact::{ CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeExactLexicalArtifactReaderV1, - CodeLexicalArtifactBuildProgressV1, CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, - CodeLexicalArtifactFinalizationStepV1, CodeLexicalArtifactOccurrenceV1, - CodeLexicalArtifactReaderV1, CodeLexicalArtifactSectionDigestV1, - CodeLexicalImportMembershipWitnessV1, VerifiedCodeLexicalArtifactV1, + CodeLexicalArtifactBatchLimitV1, CodeLexicalArtifactBuildProgressV1, + CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, + CodeLexicalArtifactFinalizationPhaseV1, CodeLexicalArtifactFinalizationStepV1, + CodeLexicalArtifactOccurrenceV1, CodeLexicalArtifactReaderV1, + CodeLexicalArtifactSectionDigestV1, CodeLexicalImportMembershipWitnessV1, + PreparedCodeLexicalArtifactPageV1, VerifiedCodeLexicalArtifactV1, }; use postings::{ByteNgramBudget, ByteNgramPostings, FuzzyTermIndex}; diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs index fbbaafd60a..507cef2736 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs @@ -12,16 +12,18 @@ use tracedecay_code_index::production::{CodeIndexExecutionControlV1, CodeIndexIn mod builder; mod format; mod postings; +mod prepared; mod reader; pub use builder::{ CodeLexicalArtifactBuildProgressV1, CodeLexicalArtifactBuilderV1, - CodeLexicalArtifactFinalizationStepV1, + CodeLexicalArtifactFinalizationPhaseV1, CodeLexicalArtifactFinalizationStepV1, }; pub use format::{ CodeLexicalArtifactOccurrenceV1, CodeLexicalArtifactSectionDigestV1, CodeLexicalImportMembershipWitnessV1, VerifiedCodeLexicalArtifactV1, }; +pub use prepared::PreparedCodeLexicalArtifactPageV1; pub use reader::{CodeExactLexicalArtifactReaderV1, CodeLexicalArtifactReaderV1}; /// Default and maximum budget for the artifact build memory ledger. @@ -29,12 +31,10 @@ pub use reader::{CodeExactLexicalArtifactReaderV1, CodeLexicalArtifactReaderV1}; /// This is a *ledger claim over tracked allocations*, not a hard RSS bound. /// The enforced ledger charges, as if simultaneous: the SQLite page-cache /// authority granted to the staging connection, the builder-retained -/// projection metadata (identity and logical-path capacities), the sealed -/// page's retained owned bytes, and a conservative arithmetic per-chunk/ -/// per-import transient upper bound — the cloned chunk, projected row, -/// field/token vectors and frequency map, serialization buffers, and -/// n-gram scratch. A page whose charge exceeds the budget is refused before -/// any preflight allocation, staging mutation, or source advance. +/// projection metadata (identity and logical-path capacities), every sealed +/// page retained by an admitted batch, every prepared relational value, and +/// the widest in-flight per-record preparation scratch. A batch whose charge +/// exceeds the budget is refused before SQLite mutation or source advance. /// /// Explicitly outside the claim (the narrowed part): SQLite's `cache_size` /// is a target the engine may transiently exceed, per-statement and @@ -49,6 +49,15 @@ pub const CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1: usize = 256 * 1024 /// target, not a hard allocator bound. pub const CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1: usize = 256 * 1024 * 1024; pub const CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1: usize = 96 * 1024 * 1024; +pub const CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1: usize = 2_000_000; +pub const CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1: usize = 256 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CodeLexicalArtifactBatchLimitV1 { + Memory, + PreparedRows, + EstimatedWriteBytes, +} /// Page-cache authority granted to artifact connections; charged in full /// against the memory ledgers because SQLite may use all of it. Sized to @@ -72,6 +81,14 @@ pub enum CodeLexicalArtifactErrorV1 { Missing(String), #[error("lexical artifact reservation is unavailable: {0}")] Unreserved(String), + #[error( + "lexical artifact page batch exceeds its {limit:?} bound: needs {required}, maximum {maximum}" + )] + BatchTooLarge { + limit: CodeLexicalArtifactBatchLimitV1, + required: usize, + maximum: usize, + }, #[error("lexical artifact operation was interrupted: {0:?}")] Interrupted(CodeIndexInterruptionV1), #[error("lexical artifact contract violation: {0}")] diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index 81f605a655..2413c99e4d 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -1,9 +1,7 @@ -use std::borrow::Cow; -use std::collections::BTreeMap; -use std::collections::btree_map::Entry; use std::fs::File; use std::path::{Path, PathBuf}; +use rayon::prelude::*; use rusqlite::types::ValueRef; use rusqlite::{Connection, OptionalExtension, Transaction, params}; use serde::{Deserialize, Serialize}; @@ -16,31 +14,29 @@ use tracedecay_code_index::production::{ VerifiedSealedLexicalSourceReceiptV1, }; use tracedecay_domain::{ - CodeSearchChunkAnchorV1, CodeSearchChunkV1, ExactFieldV1, ExactTechnicalTermV1, - FileOccurrenceId, ManifestDigest, + CodeSearchChunkAnchorV1, CodeSearchChunkV1, ExactTechnicalTermV1, FileOccurrenceId, + ManifestDigest, }; use tracedecay_private_fs::{create_private_file_retained, open_private_file}; use super::format::{ - ArtifactRowV1, CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1, CodeLexicalArtifactSectionDigestV1, + CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1, CodeLexicalArtifactSectionDigestV1, RECEIPT_RESERVATION_BYTES, SECTION_NAMES, VerifiedCodeLexicalArtifactV1, artifact_digest, - decode_padded_receipt, decode_padded_receipt_with_control, encode_exact_field, encode_field, - metadata_digest, new_verified_receipt, padded_receipt, verify_required_artifact_indexes, -}; -use super::postings::{ - NGRAM_NORMALIZED, NGRAM_RAW_OVERRIDE, document_ngram_scratch, insert_document_ngrams, + decode_padded_receipt, decode_padded_receipt_with_control, metadata_digest, + new_verified_receipt, padded_receipt, verify_required_artifact_indexes, }; +use super::postings::document_ngram_scratch; +use super::prepared::{PreparedCodeLexicalArtifactPageV1, prepare_page as prepare_page_values}; use super::{ ARTIFACT_SQLITE_CACHE_BYTES, CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, - CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1, CodeLexicalArtifactErrorV1, checkpoint, - open_builder_connection, sqlite_corrupt, sqlite_error, + CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1, + CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1, + CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1, CodeLexicalArtifactBatchLimitV1, + CodeLexicalArtifactErrorV1, checkpoint, open_builder_connection, sqlite_corrupt, sqlite_error, }; use crate::retrieval::lexical::LexicalFieldV1; -use super::super::{ - CodeLexicalProjectionMetadataV1, ProjectedChunkV1, canonical_projected_exact_term, - exact_field_for_kind, -}; +use super::super::CodeLexicalProjectionMetadataV1; const DOCUMENT_TERM_POSTINGS_QUERY: &str = "SELECT field, term, frequency FROM term_postings INDEXED BY term_postings_by_document WHERE document_id = ?1 ORDER BY field, term"; const DOCUMENT_EXACT_POSTINGS_QUERY: &str = @@ -280,9 +276,16 @@ pub struct CodeLexicalArtifactBuildProgressV1 { /// `Pending` persists its section and row cursor in the staging database, so /// callers can yield, restart the process, and continue without reopening the /// sealed source or replaying its pages. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CodeLexicalArtifactFinalizationPhaseV1 { + IndexBuild, + Verification, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum CodeLexicalArtifactFinalizationStepV1 { Pending { + phase: CodeLexicalArtifactFinalizationPhaseV1, completed_sections: u64, completed_rows: u64, }, @@ -310,12 +313,60 @@ enum PersistedFinalizationPhaseV1 { Verify, } -struct FinalizationWakeMetricsV1; +impl PersistedFinalizationPhaseV1 { + const fn public(self) -> CodeLexicalArtifactFinalizationPhaseV1 { + match self { + Self::Build => CodeLexicalArtifactFinalizationPhaseV1::IndexBuild, + Self::Verify => CodeLexicalArtifactFinalizationPhaseV1::Verification, + } + } +} + +struct FinalizationWakeMetricsV1 { + #[cfg(feature = "hotpath")] + rows: u64, +} + +struct FinalizationTransactionMetricsV1 { + #[cfg(feature = "hotpath")] + committed: bool, +} + +impl FinalizationTransactionMetricsV1 { + #[inline(always)] + const fn new() -> Self { + Self { + #[cfg(feature = "hotpath")] + committed: false, + } + } + + #[inline(always)] + fn mark_committed(&mut self) { + #[cfg(feature = "hotpath")] + { + self.committed = true; + } + } +} + +impl Drop for FinalizationTransactionMetricsV1 { + fn drop(&mut self) { + #[cfg(feature = "hotpath")] + if !self.committed { + // Dropping an uncommitted rusqlite transaction rolls it back. + hotpath::gauge!("query.artifact.finalization.rollback_total").inc(1u64); + } + } +} impl FinalizationWakeMetricsV1 { #[inline(always)] fn new() -> Self { - Self + Self { + #[cfg(feature = "hotpath")] + rows: 0, + } } #[inline(always)] @@ -383,12 +434,34 @@ impl FinalizationWakeMetricsV1 { #[cfg(feature = "hotpath")] hotpath::gauge!("query.artifact.finalization.section_probes_total").inc(1u64); } + + #[inline(always)] + fn add_rows(&mut self, rows: usize) -> Result<(), CodeLexicalArtifactErrorV1> { + #[cfg(feature = "hotpath")] + { + self.rows = self + .rows + .checked_add(u64::try_from(rows).map_err(contract_number)?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact finalization wake row metric overflowed".to_owned(), + ) + })?; + } + #[cfg(not(feature = "hotpath"))] + let _ = rows; + Ok(()) + } } -#[inline(always)] -fn record_finalization_row() { - #[cfg(feature = "hotpath")] - hotpath::gauge!("query.artifact.finalization.rows_total").inc(1u64); +impl Drop for FinalizationWakeMetricsV1 { + fn drop(&mut self) { + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.finalization.wakes_total").inc(1u64); + hotpath::gauge!("query.artifact.finalization.rows_total").inc(self.rows); + } + } } /// Stable identity of the private staging authority, captured from an exact @@ -530,14 +603,14 @@ impl CodeLexicalArtifactBuilderV1 { /// The deterministic ledger charge admitting `page` would add on top of /// the fixed charge: the page's retained owned bytes plus the - /// arithmetic per-chunk/per-import transient upper bound (chunk clone, - /// projected row, field/token/frequency maps, JSON buffers, and n-gram - /// scratch), without allocating during admission. + /// summed per-record preparation upper bound (projected rows, postings, + /// serialization, and n-gram scratch), without allocating during + /// admission. pub fn page_ledger_charge_bytes( &self, page: &VerifiedSealedLexicalPageV1, ) -> Result { - let transient = page_transient_peak_bytes(&self.metadata, page, usize::MAX)?; + let transient = page_preparation_upper_bound_bytes(&self.metadata, page)?; page.retained_owned_bytes() .checked_add(transient) .ok_or_else(|| { @@ -547,14 +620,59 @@ impl CodeLexicalArtifactBuilderV1 { }) } - #[hotpath::measure(label = "query.artifact.append_page")] + /// Conservative pre-preparation charge for retaining `pages` and every + /// page's derived output/scratch upper bound. The exact post-preparation + /// charge is carried by [`PreparedCodeLexicalArtifactPageV1`]. + pub fn page_batch_ledger_charge_bytes( + &self, + pages: &[VerifiedSealedLexicalPageV1], + ) -> Result { + page_batch_ledger_charge_bytes(&self.metadata, pages) + } + + /// Append one page through the canonical atomic batch path. pub fn append_page( &mut self, page: &VerifiedSealedLexicalPageV1, control: &dyn CodeIndexExecutionControlV1, ) -> Result { + self.append_pages(std::slice::from_ref(page), control) + } + + /// Atomically append an ordered, contiguous batch of verified source + /// pages. Replayed prefix pages are verified idempotently; every fresh + /// page and its derived rows commit in one SQLite transaction. + #[hotpath::measure(label = "query.artifact.append_pages")] + pub fn append_pages( + &mut self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result { + let result = (|| { + let prepared = self.prepare_pages(pages, control)?; + self.append_prepared_pages_inner(&prepared, control) + })(); + record_batch_outcome(&result); + result + } + + /// Prepare the fresh suffix of one ordered source batch outside SQLite. + /// Work runs on the canonical bounded indexing pool, preserves input + /// order, holds one background CPU permit per active unit, and drains all + /// workers before returning any failure. + #[hotpath::measure(label = "query.artifact.prepare_pages")] + pub fn prepare_pages( + &self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result, CodeLexicalArtifactErrorV1> { checkpoint(control)?; self.verify_path_binding()?; + if pages.is_empty() { + return Err(CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page batches must be non-empty".to_owned(), + )); + } if read_receipt(&self.connection)?.is_some() { return Err(CodeLexicalArtifactErrorV1::Contract( "finalized lexical artifacts do not accept more source pages".to_owned(), @@ -565,68 +683,195 @@ impl CodeLexicalArtifactBuilderV1 { "lexical artifact finalization has started; source pages are immutable".to_owned(), )); } - if page.retained_owned_bytes() > CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 { - return Err(CodeLexicalArtifactErrorV1::Contract(format!( - "sealed lexical page retained bytes exceed the {}-byte artifact input bound", - CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 - ))); - } - let current = progress(&self.connection)?; - let previous = cursor_before_page(&self.connection, page.page_ordinal())?; - page.verify_transition(previous.as_ref()) - .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; - if page.page_ordinal() < current.next_page_ordinal { - verify_replayed_page(&self.connection, page)?; - hotpath::gauge!("query.artifact.pages").set(current.next_page_ordinal); - hotpath::gauge!("query.artifact.rows").set(current.completed_chunks); - hotpath::gauge!("query.artifact.bytes").set(current.completed_payload_bytes); - return Ok(current); + + hotpath::gauge!("query.artifact.batch.admission_total").inc(1u64); + let (current, fresh_start) = hotpath::measure_block!("query.artifact.batch.admission", { + prepare_page_batch_admission( + &self.connection, + &self.metadata, + self.fixed_ledger_charge_bytes, + self.memory_budget_bytes, + pages, + ) + })?; + let fresh_pages = &pages[fresh_start..]; + if fresh_pages.is_empty() { + return Ok(Vec::new()); } - if page.page_ordinal() != current.next_page_ordinal { + let previous_cursors = fresh_pages + .iter() + .enumerate() + .map(|(index, _)| { + if index == 0 { + current.next_cursor.as_ref().map(encode_cursor).transpose() + } else { + encode_cursor(fresh_pages[index - 1].next_cursor()).map(Some) + } + }) + .collect::, _>>()?; + let scratch = fresh_pages + .iter() + .map(|page| page_transient_peak_bytes(&self.metadata, page, usize::MAX)) + .collect::, _>>()?; + let metadata = &self.metadata; + let prepared = hotpath::measure_block!("query.artifact.batch.parallel_prepare", { + tracedecay_code_index::parallelism::install(|| { + fresh_pages + .par_iter() + .zip(previous_cursors.into_par_iter()) + .zip(scratch.into_par_iter()) + .enumerate() + .map(|(index, ((page, previous_cursor), scratch_bytes))| { + tracedecay_code_index::parallelism::with_background_cpu_permit(|| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + prepare_page_values( + metadata, + page, + previous_cursor, + scratch_bytes, + control, + ) + })) + .unwrap_or_else(|payload| { + Err(CodeLexicalArtifactErrorV1::Io( + tracedecay_code_index::parallelism::CodeIndexParallelismErrorV1::from_panic_payload( + index, + &*payload, + ) + .to_string(), + )) + }) + }) + }) + .collect::>() + }) + }) + .map_err(|error| CodeLexicalArtifactErrorV1::Io(error.to_string()))? + .into_iter() + .collect::, _>>()?; + admit_prepared_page_batch( + self.fixed_ledger_charge_bytes, + self.memory_budget_bytes, + &prepared, + )?; + record_prepared_batch_metrics(&prepared); + Ok(prepared) + } + + /// Atomically admit an ordered prepared batch. The values carry no + /// durable authority until this method commits their rows and receipts. + pub fn append_prepared_pages( + &mut self, + pages: &[PreparedCodeLexicalArtifactPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result { + let result = self.append_prepared_pages_inner(pages, control); + record_batch_outcome(&result); + result + } + + fn append_prepared_pages_inner( + &mut self, + pages: &[PreparedCodeLexicalArtifactPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result { + checkpoint(control)?; + self.verify_path_binding()?; + if read_receipt(&self.connection)?.is_some() { return Err(CodeLexicalArtifactErrorV1::Contract( - "sealed lexical pages must be appended in exact ordinal order".to_owned(), + "finalized lexical artifacts do not accept more source pages".to_owned(), )); } - if let Some(cumulative) = ¤t.cumulative_source_digest - && page.page_ordinal() > 0 - && cumulative == page.cumulative_digest() - { + if finalization_started(&self.connection)? { return Err(CodeLexicalArtifactErrorV1::Contract( - "sealed lexical page did not advance its cumulative digest".to_owned(), + "lexical artifact finalization has started; source pages are immutable".to_owned(), )); } - // Ledger refusal precedes the staging transaction: a page that does - // not fit the build memory budget leaves progress untouched. - admit_page_within_memory_budget( - &self.metadata, + let current = progress(&self.connection)?; + if pages.is_empty() { + record_artifact_progress(¤t); + return Ok(current); + } + validate_prepared_page_batch(¤t, pages)?; + admit_prepared_page_batch( self.fixed_ledger_charge_bytes, self.memory_budget_bytes, - page, + pages, )?; - - let transaction = self.connection.transaction().map_err(sqlite_error)?; - append_imports(&transaction, page, control)?; - append_page_rows( - &transaction, - &self.metadata, - current.completed_chunks, - page, - control, - )?; - insert_source_page(&transaction, page)?; - checkpoint(control)?; - transaction.commit().map_err(sqlite_error)?; + hotpath::measure_block!("query.artifact.batch.sqlite", { + let transaction = self.connection.transaction().map_err(sqlite_error)?; + let mutation = (|| { + hotpath::measure_block!("query.artifact.batch.imports", { + for page in pages { + append_prepared_imports(&transaction, page, control)?; + } + Ok::<(), CodeLexicalArtifactErrorV1>(()) + })?; + record_batch_import_metrics(pages); + hotpath::measure_block!( + "query.artifact.batch.postings", + append_prepared_postings(&transaction, pages, control) + )?; + record_batch_posting_metrics(pages); + hotpath::measure_block!( + "query.artifact.batch.rows", + append_prepared_rows(&transaction, pages, control) + )?; + record_batch_row_metrics(pages); + hotpath::measure_block!("query.artifact.batch.receipts", { + for page in pages { + insert_prepared_source_page(&transaction, page)?; + } + Ok::<(), CodeLexicalArtifactErrorV1>(()) + })?; + record_batch_receipt_metrics(pages); + checkpoint(control) + })(); + if let Err(error) = mutation { + hotpath::gauge!("query.artifact.batch.rollbacks_total").inc(1u64); + hotpath::measure_block!( + "query.artifact.batch.rollback", + transaction.rollback().map_err(sqlite_error) + )?; + return Err(error); + } + hotpath::gauge!("query.artifact.batch.commit_attempts_total").inc(1u64); + let commit = hotpath::measure_block!( + "query.artifact.batch.commit", + transaction.commit().map_err(sqlite_error) + ); + if commit.is_ok() { + hotpath::gauge!("query.artifact.batch.commit_succeeded_total").inc(1u64); + } + commit + })?; + // Do not observe cancellation between durable COMMIT and publishing + // its exact progress. The source callback must be able to advance its + // cursor once the whole batch has committed. let progress = progress(&self.connection)?; - hotpath::gauge!("query.artifact.pages").set(progress.next_page_ordinal); - hotpath::gauge!("query.artifact.rows").set(progress.completed_chunks); - hotpath::gauge!("query.artifact.bytes").set(progress.completed_payload_bytes); + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.batch.committed_pages_total") + .inc(u64::try_from(pages.len()).map_err(contract_number)?); + hotpath::gauge!("query.artifact.batch.committed_chunks_total").inc( + pages + .iter() + .try_fold(0u64, |total, page| total.checked_add(page.chunk_count)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact committed chunk count overflowed".to_owned(), + ) + })?, + ); + } + record_artifact_progress(&progress); Ok(progress) } /// Advance durable receipt construction without rereading the sealed /// generation. `maximum_work` bounds the number of staged rows (or empty /// section completions) this call may consume. - #[hotpath::measure(label = "query.artifact.advance_finalization")] + #[hotpath::measure(label = "query.artifact.finalization.digest_verify_wake")] pub fn advance_finalization( &mut self, source: &VerifiedSealedLexicalSourceReceiptV1, @@ -638,7 +883,7 @@ impl CodeLexicalArtifactBuilderV1 { "lexical artifact finalization work budget must be non-zero".to_owned(), )); } - let wake_metrics = FinalizationWakeMetricsV1::new(); + let mut wake_metrics = FinalizationWakeMetricsV1::new(); checkpoint(control)?; self.verify_path_binding()?; verify_artifact_state_metadata( @@ -658,14 +903,16 @@ impl CodeLexicalArtifactBuilderV1 { verify_staged_source_tail(&self.connection, source)?; let content_epoch = content_epoch(&self.connection)?; let transaction = self.connection.transaction().map_err(sqlite_error)?; + let mut transaction_metrics = FinalizationTransactionMetricsV1::new(); store_finalization_state( &transaction, &PersistedFinalizationStateV1::new(content_epoch, source)?, )?; - transaction.commit().map_err(sqlite_error)?; + commit_finalization_transaction(transaction, &mut transaction_metrics)?; } let transaction = self.connection.transaction().map_err(sqlite_error)?; + let mut transaction_metrics = FinalizationTransactionMetricsV1::new(); let mut state = load_finalization_state(&transaction)?.ok_or_else(|| { CodeLexicalArtifactErrorV1::Corrupt( "lexical artifact finalization marker disappeared".to_owned(), @@ -692,6 +939,7 @@ impl CodeLexicalArtifactBuilderV1 { wake_metrics.probe(); let rows = advance_section_rows(&transaction, section, &mut state, remaining_work, control)?; + wake_metrics.add_rows(rows)?; if rows > 0 { remaining_work = remaining_work.checked_sub(rows).ok_or_else(|| { CodeLexicalArtifactErrorV1::Corrupt( @@ -743,8 +991,9 @@ impl CodeLexicalArtifactBuilderV1 { if state.section_ordinal < section_count { store_finalization_state(&transaction, &state)?; checkpoint(control)?; - transaction.commit().map_err(sqlite_error)?; + commit_finalization_transaction(transaction, &mut transaction_metrics)?; let step = CodeLexicalArtifactFinalizationStepV1::Pending { + phase: state.phase.public(), completed_sections: u64::try_from(state.completed_sections.len()) .map_err(contract_number)?, completed_rows: state.completed_rows, @@ -761,8 +1010,9 @@ impl CodeLexicalArtifactBuilderV1 { state.section_accumulator = initial_section_accumulator(SECTION_NAMES[0])?.to_vec(); store_finalization_state(&transaction, &state)?; checkpoint(control)?; - transaction.commit().map_err(sqlite_error)?; + commit_finalization_transaction(transaction, &mut transaction_metrics)?; let step = CodeLexicalArtifactFinalizationStepV1::Pending { + phase: state.phase.public(), completed_sections: u64::try_from(state.completed_sections.len()) .map_err(contract_number)?, completed_rows: state.completed_rows, @@ -811,7 +1061,7 @@ impl CodeLexicalArtifactBuilderV1 { .execute("DELETE FROM finalization_state WHERE singleton = 1", []) .map_err(sqlite_error)?; checkpoint(control)?; - transaction.commit().map_err(sqlite_error)?; + commit_finalization_transaction(transaction, &mut transaction_metrics)?; let step = CodeLexicalArtifactFinalizationStepV1::Ready(Box::new(receipt)); record_finalization_step(&step); Ok(step) @@ -931,6 +1181,11 @@ fn private_staging_error(error: std::io::Error) -> CodeLexicalArtifactErrorV1 { /// Amortized per-entry b-tree node overhead (headers and edge pointers) /// charged on top of each entry's key/value payload. const BTREE_MAP_ENTRY_OVERHEAD_BYTES: usize = 16; +const PERSISTED_CURSOR_DIGEST_FIELDS: usize = 4; +const PERSISTED_CURSOR_U64_FIELDS: usize = 9; +const MAX_DECIMAL_U64_BYTES: usize = 20; +const PERSISTED_CURSOR_JSON_DELIMITERS_BYTES: usize = 64; +const PREPARED_PAGE_DIGEST_FIELDS: usize = 3; /// Validate a caller-selected build memory budget and return the fixed /// ledger charge it must absorb before any page is admitted. @@ -1030,35 +1285,264 @@ fn metadata_retained_bytes(metadata: &CodeLexicalProjectionMetadataV1) -> usize }) } -/// Refuse a page whose ledger charge does not fit the remaining budget. -/// -/// Runs before the staging transaction, so a refusal never mutates staged -/// progress and an upstream caller can decline the page before advancing its -/// sealed source cursor. -fn admit_page_within_memory_budget( +fn page_batch_ledger_charge_bytes( + metadata: &CodeLexicalProjectionMetadataV1, + pages: &[VerifiedSealedLexicalPageV1], +) -> Result { + let retained = pages.iter().try_fold(0usize, |total, page| { + total + .checked_add(page.retained_owned_bytes()) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch retained-byte charge overflowed".to_owned(), + ) + }) + })?; + let prepared_retained = pages.iter().try_fold(0usize, |total, page| { + page_prepared_retained_upper_bound_bytes(metadata, page).and_then(|page_bound| { + total.checked_add(page_bound).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch prepared-retained charge overflowed".to_owned(), + ) + }) + }) + })?; + let active_workers = tracedecay_code_index::parallelism::indexing_workers().min(pages.len()); + let mut scratch = pages + .iter() + .map(|page| page_transient_peak_bytes(metadata, page, usize::MAX)) + .collect::, _>>()?; + scratch.sort_unstable_by(|left, right| right.cmp(left)); + let active_scratch = + scratch + .into_iter() + .take(active_workers) + .try_fold(0usize, |total, charge| { + total.checked_add(charge).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch preparation scratch charge overflowed".to_owned(), + ) + }) + })?; + retained + .checked_add(prepared_retained) + .and_then(|bytes| bytes.checked_add(active_scratch)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch ledger charge overflowed".to_owned(), + ) + }) +} + +/// Refuse a batch unless its retained source pages, all prepared outputs, and +/// one scratch peak per active worker fit together. Admission runs before the +/// staging transaction, so refusal leaves builder and source progress intact. +fn admit_page_batch_within_memory_budget( metadata: &CodeLexicalProjectionMetadataV1, fixed_ledger_charge_bytes: usize, memory_budget_bytes: usize, - page: &VerifiedSealedLexicalPageV1, + pages: &[VerifiedSealedLexicalPageV1], ) -> Result<(), CodeLexicalArtifactErrorV1> { - let refusal = |needed: usize| { - CodeLexicalArtifactErrorV1::Contract(format!( - "sealed lexical page needs at least {needed} ledger bytes on top of the {fixed_ledger_charge_bytes}-byte fixed charge, exceeding the {memory_budget_bytes}-byte build memory budget" - )) - }; - let headroom = memory_budget_bytes.saturating_sub(fixed_ledger_charge_bytes); - let retained = page.retained_owned_bytes(); - if retained > headroom { - return Err(refusal(retained)); + for page in pages { + if page.retained_owned_bytes() > CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 { + return Err(CodeLexicalArtifactErrorV1::Contract(format!( + "sealed lexical page retained bytes exceed the {}-byte artifact input bound", + CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 + ))); + } } - let transient_headroom = headroom - retained; - let transient = page_transient_peak_bytes(metadata, page, transient_headroom)?; - if transient > transient_headroom { - return Err(refusal(retained.saturating_add(transient))); + let additional = page_batch_ledger_charge_bytes(metadata, pages)?; + let required = fixed_ledger_charge_bytes + .checked_add(additional) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch total ledger charge overflowed".to_owned(), + ) + })?; + if required > memory_budget_bytes { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::Memory, + required, + memory_budget_bytes, + )); } Ok(()) } +fn admit_prepared_page_batch( + fixed_ledger_charge_bytes: usize, + memory_budget_bytes: usize, + pages: &[PreparedCodeLexicalArtifactPageV1], +) -> Result<(), CodeLexicalArtifactErrorV1> { + let source_retained = pages.iter().try_fold(0usize, |total, page| { + total + .checked_add(page.source_retained_bytes()) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical batch source-retained charge overflowed".to_owned(), + ) + }) + })?; + let prepared_retained = pages.iter().try_fold(0usize, |total, page| { + total + .checked_add(page.retained_owned_bytes()) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical batch retained charge overflowed".to_owned(), + ) + }) + })?; + let active_workers = tracedecay_code_index::parallelism::indexing_workers().min(pages.len()); + let mut scratch = pages + .iter() + .map(PreparedCodeLexicalArtifactPageV1::preparation_scratch_bytes) + .collect::>(); + scratch.sort_unstable_by(|left, right| right.cmp(left)); + let active_scratch = scratch + .into_iter() + .take(active_workers) + .try_fold(0usize, |total, charge| total.checked_add(charge)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical batch active-worker scratch charge overflowed".to_owned(), + ) + })?; + let required = fixed_ledger_charge_bytes + .checked_add(source_retained) + .and_then(|bytes| bytes.checked_add(prepared_retained)) + .and_then(|bytes| bytes.checked_add(active_scratch)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical batch total ledger charge overflowed".to_owned(), + ) + })?; + if required > memory_budget_bytes { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::Memory, + required, + memory_budget_bytes, + )); + } + let estimated_rows = sum_prepared_metric( + pages, + PreparedCodeLexicalArtifactPageV1::estimated_write_rows, + "prepared lexical batch row estimate overflowed", + )?; + if estimated_rows > CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1 { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::PreparedRows, + estimated_rows, + CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1, + )); + } + let estimated_write_bytes = sum_prepared_metric( + pages, + PreparedCodeLexicalArtifactPageV1::estimated_write_bytes, + "prepared lexical batch write estimate overflowed", + )?; + if estimated_write_bytes > CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1 { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::EstimatedWriteBytes, + estimated_write_bytes, + CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1, + )); + } + Ok(()) +} + +fn sum_prepared_metric( + pages: &[PreparedCodeLexicalArtifactPageV1], + metric: impl Fn(&PreparedCodeLexicalArtifactPageV1) -> usize, + overflow: &str, +) -> Result { + pages.iter().try_fold(0usize, |total, page| { + total + .checked_add(metric(page)) + .ok_or_else(|| CodeLexicalArtifactErrorV1::Contract(overflow.to_owned())) + }) +} + +fn batch_limit( + limit: CodeLexicalArtifactBatchLimitV1, + required: usize, + maximum: usize, +) -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::BatchTooLarge { + limit, + required, + maximum, + } +} + +fn prepare_page_batch_admission( + connection: &Connection, + metadata: &CodeLexicalProjectionMetadataV1, + fixed_ledger_charge_bytes: usize, + memory_budget_bytes: usize, + pages: &[VerifiedSealedLexicalPageV1], +) -> Result<(CodeLexicalArtifactBuildProgressV1, usize), CodeLexicalArtifactErrorV1> { + admit_page_batch_within_memory_budget( + metadata, + fixed_ledger_charge_bytes, + memory_budget_bytes, + pages, + )?; + let current = progress(connection)?; + let mut fresh_start = pages.len(); + let mut expected_fresh_ordinal = current.next_page_ordinal; + for (index, page) in pages.iter().enumerate() { + if let Some(previous_page) = index.checked_sub(1).and_then(|index| pages.get(index)) { + let expected = previous_page.page_ordinal().checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "sealed lexical page ordinal overflowed".to_owned(), + ) + })?; + if page.page_ordinal() != expected { + return Err(CodeLexicalArtifactErrorV1::Contract( + "sealed lexical page batches must be contiguous and ordered".to_owned(), + )); + } + } + let persisted_previous; + let previous = if index == 0 { + persisted_previous = cursor_before_page(connection, page.page_ordinal())?; + persisted_previous.as_ref() + } else { + pages + .get(index - 1) + .map(VerifiedSealedLexicalPageV1::next_cursor) + }; + page.verify_transition(previous) + .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; + if page.page_ordinal() < current.next_page_ordinal { + verify_replayed_page(connection, page)?; + continue; + } + if page.page_ordinal() != expected_fresh_ordinal { + return Err(CodeLexicalArtifactErrorV1::Contract( + "sealed lexical pages must be appended in exact ordinal order".to_owned(), + )); + } + if fresh_start == pages.len() { + fresh_start = index; + if let Some(cumulative) = ¤t.cumulative_source_digest + && page.page_ordinal() > 0 + && cumulative == page.cumulative_digest() + { + return Err(CodeLexicalArtifactErrorV1::Contract( + "sealed lexical page did not advance its cumulative digest".to_owned(), + )); + } + } + expected_fresh_ordinal = expected_fresh_ordinal.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "sealed lexical page ordinal overflowed".to_owned(), + ) + })?; + } + Ok((current, fresh_start)) +} + /// The widest transient upper bound one staged chunk or import can require. /// It is evaluated a record at a time without allocations and aborts once /// `abort_above` is exceeded; that returned lower bound already fails @@ -1084,6 +1568,143 @@ fn page_transient_peak_bytes( Ok(peak) } +/// Conservative output-plus-scratch upper bound before one page is prepared. +/// Every derived retained value coexists with that page's widest transient +/// record allocation. +fn page_preparation_upper_bound_bytes( + metadata: &CodeLexicalProjectionMetadataV1, + page: &VerifiedSealedLexicalPageV1, +) -> Result { + page_prepared_retained_upper_bound_bytes(metadata, page)? + .checked_add(page_transient_peak_bytes(metadata, page, usize::MAX)?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page preparation charge overflowed".to_owned(), + ) + }) +} + +/// Conservative retained output for one fully prepared page. Every record's +/// owned projection remains live until the ordered batch commits, while only +/// the widest per-worker scratch allocation is charged separately. +fn page_prepared_retained_upper_bound_bytes( + metadata: &CodeLexicalProjectionMetadataV1, + page: &VerifiedSealedLexicalPageV1, +) -> Result { + let chunk_bytes = page.chunks().iter().try_fold(0usize, |total, admitted| { + total + .checked_add(projected_chunk_prepared_retained_upper_bound_bytes( + metadata, admitted, + )?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page preparation charge overflowed".to_owned(), + ) + }) + })?; + let record_bytes = page + .imports() + .iter() + .try_fold(chunk_bytes, |total, evidence| { + total + .checked_add(import_transient_bytes(evidence)?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page preparation charge overflowed".to_owned(), + ) + }) + })?; + record_bytes + .checked_add(prepared_page_authority_upper_bound_bytes(page)?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page preparation charge overflowed".to_owned(), + ) + }) +} + +fn projected_chunk_prepared_retained_upper_bound_bytes( + metadata: &CodeLexicalProjectionMetadataV1, + admitted: &ExtractionAdmittedCodeSearchChunkV1, +) -> Result { + let transient = projected_chunk_transient_bytes(metadata, admitted)?; + let text_bytes = admitted.chunk().sanitized_text.as_str().len(); + let normalized_text_bytes = text_bytes.saturating_mul(3); + let (_, normalized_scratch) = document_ngram_scratch(normalized_text_bytes)?; + let (_, raw_scratch) = document_ngram_scratch(text_bytes)?; + // Preparation retains `(kind, ngram)` tuples, not the `u32` scratch + // values. Vec growth can reserve up to the next geometric capacity, so + // two tuple slots are charged for every authorized scratch slot. + let ngram_tuple_slots = normalized_scratch + .checked_add(raw_scratch) + .and_then(|bytes| bytes.checked_div(std::mem::size_of::())) + .and_then(|slots| slots.checked_mul(2)) + .and_then(|slots| slots.checked_mul(std::mem::size_of::<(i64, i64)>())) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared n-gram slot charge overflowed".to_owned(), + ) + })?; + transient.checked_add(ngram_tuple_slots).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared document charge overflowed".to_owned(), + ) + }) +} + +/// Page-level prepared ownership that is not attributable to one chunk or +/// import. Duplicating the source page's complete retained charge covers +/// vector capacities and typed identities; the explicit cursor envelope +/// covers both serialized cursor copies and their JSON framing without +/// allocating during admission. +fn prepared_page_authority_upper_bound_bytes( + page: &VerifiedSealedLexicalPageV1, +) -> Result { + let digest_bytes = page + .page_digest() + .as_str() + .len() + .max(page.cumulative_digest().as_str().len()) + .max(page.next_cursor().import_dictionary_digest().as_str().len()); + let numeric_bytes = PERSISTED_CURSOR_U64_FIELDS + .checked_mul(MAX_DECIMAL_U64_BYTES) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared cursor numeric authority overflowed".to_owned(), + ) + })?; + let cursor_bytes = digest_bytes + .checked_mul(PERSISTED_CURSOR_DIGEST_FIELDS) + .and_then(|bytes| bytes.checked_add(numeric_bytes)) + .and_then(|bytes| bytes.checked_add(PERSISTED_CURSOR_JSON_DELIMITERS_BYTES)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared cursor authority overflowed".to_owned(), + ) + })?; + let prepared_digest_bytes = digest_bytes + .checked_mul(PREPARED_PAGE_DIGEST_FIELDS) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared digest authority overflowed".to_owned(), + ) + })?; + let persisted_cursor_bytes = cursor_bytes.checked_mul(2).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared cursor copies overflowed".to_owned(), + ) + })?; + page.retained_owned_bytes() + .checked_add(std::mem::size_of::()) + .and_then(|bytes| bytes.checked_add(prepared_digest_bytes)) + .and_then(|bytes| bytes.checked_add(persisted_cursor_bytes)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared page authority overflowed".to_owned(), + ) + }) +} + /// Conservative transient bytes staging one admitted chunk may allocate. /// /// This is intentionally arithmetic-only: budget refusal must not clone, @@ -1227,24 +1848,203 @@ fn import_transient_bytes( .saturating_add(256)) } -fn insert_source_page( +fn validate_prepared_page_batch( + current: &CodeLexicalArtifactBuildProgressV1, + pages: &[PreparedCodeLexicalArtifactPageV1], +) -> Result<(), CodeLexicalArtifactErrorV1> { + let mut expected_ordinal = current.next_page_ordinal; + let mut expected_document = current.completed_chunks; + let mut expected_previous = current + .next_cursor + .as_ref() + .map(encode_cursor) + .transpose()?; + for page in pages { + if page.page_ordinal != expected_ordinal || page.previous_cursor != expected_previous { + return Err(CodeLexicalArtifactErrorV1::Contract( + "prepared lexical pages must continue the exact durable cursor in order".to_owned(), + )); + } + if usize::try_from(page.chunk_count).map_err(contract_number)? != page.documents.len() + || usize::try_from(page.import_count).map_err(contract_number)? != page.imports.len() + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "prepared lexical page cardinality disagrees with its source receipt".to_owned(), + )); + } + for document in &page.documents { + if u64::try_from(document.document_id).map_err(contract_number)? != expected_document { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "prepared lexical document ids are not contiguous".to_owned(), + )); + } + expected_document = expected_document.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical document count overflowed".to_owned(), + ) + })?; + } + let next_cursor = decode_cursor(&page.next_cursor)?; + if next_cursor.next_page_ordinal() + != expected_ordinal.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical page ordinal overflowed".to_owned(), + ) + })? + || next_cursor.emitted_chunks() != expected_document + || next_cursor.cumulative_digest() != &page.cumulative_digest + || next_cursor.import_dictionary_digest() != &page.import_dictionary_digest + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "prepared lexical page receipt disagrees with its exact next cursor".to_owned(), + )); + } + expected_ordinal = next_cursor.next_page_ordinal(); + expected_previous = Some(page.next_cursor.clone()); + } + Ok(()) +} + +fn append_prepared_imports( transaction: &Transaction<'_>, - page: &VerifiedSealedLexicalPageV1, + page: &PreparedCodeLexicalArtifactPageV1, + control: &dyn CodeIndexExecutionControlV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + for import in &page.imports { + checkpoint(control)?; + transaction + .execute( + "INSERT INTO import_evidence(canonical, evidence) VALUES (?1, ?1)", + params![import.canonical.as_slice()], + ) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + transaction + .execute( + "INSERT INTO import_integrity(canonical, digest) VALUES (?1, ?2)", + params![ + import.canonical.as_slice(), + import.integrity_digest.as_str() + ], + ) + .map_err(sqlite_error)?; + } + Ok(()) +} + +fn append_prepared_postings( + transaction: &Transaction<'_>, + pages: &[PreparedCodeLexicalArtifactPageV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + for page in pages { + for document in &page.documents { + checkpoint(control)?; + for (field, total_length) in &document.field_stats { + transaction + .prepare_cached( + "INSERT INTO field_stats(field, total_length) VALUES (?1, ?2) ON CONFLICT(field) DO UPDATE SET total_length = total_length + excluded.total_length", + ) + .map_err(sqlite_error)? + .execute(params![field.as_str(), total_length]) + .map_err(sqlite_error)?; + } + for posting in &document.term_postings { + transaction + .prepare_cached( + "INSERT INTO term_postings(field, term, document_id, frequency) VALUES (?1, ?2, ?3, ?4)", + ) + .map_err(sqlite_error)? + .execute(params![ + posting.field.as_str(), + posting.term.as_str(), + document.document_id, + posting.frequency + ]) + .map_err(sqlite_error)?; + transaction + .prepare_cached( + "INSERT INTO term_stats(field, term, document_frequency) VALUES (?1, ?2, 1) ON CONFLICT(field, term) DO UPDATE SET document_frequency = document_frequency + 1", + ) + .map_err(sqlite_error)? + .execute(params![posting.field.as_str(), posting.term.as_str()]) + .map_err(sqlite_error)?; + if posting.vocabulary { + transaction + .prepare_cached("INSERT OR IGNORE INTO vocabulary(term) VALUES (?1)") + .map_err(sqlite_error)? + .execute([&posting.term]) + .map_err(sqlite_error)?; + } + } + for (field, term) in &document.exact_postings { + transaction + .prepare_cached( + "INSERT OR IGNORE INTO exact_postings(field, term, document_id) VALUES (?1, ?2, ?3)", + ) + .map_err(sqlite_error)? + .execute(params![field.as_str(), term.as_slice(), document.document_id]) + .map_err(sqlite_error)?; + } + for (kind, ngram) in &document.ngram_postings { + transaction + .prepare_cached( + "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (?1, ?2, ?3)", + ) + .map_err(sqlite_error)? + .execute(params![kind, ngram, document.document_id]) + .map_err(sqlite_error)?; + } + } + } + Ok(()) +} + +fn append_prepared_rows( + transaction: &Transaction<'_>, + pages: &[PreparedCodeLexicalArtifactPageV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + for page in pages { + for document in &page.documents { + checkpoint(control)?; + transaction + .execute( + "INSERT INTO rows(document_id, chunk_id, row) VALUES (?1, ?2, ?3)", + params![ + document.document_id, + document.chunk_id.as_str(), + document.row.as_slice() + ], + ) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + transaction + .execute( + "INSERT INTO document_integrity(document_id, digest) VALUES (?1, ?2)", + params![document.document_id, document.integrity_digest.as_str()], + ) + .map_err(sqlite_error)?; + } + } + Ok(()) +} + +fn insert_prepared_source_page( + transaction: &Transaction<'_>, + page: &PreparedCodeLexicalArtifactPageV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { - let cursor = encode_cursor(page.next_cursor())?; transaction .execute( "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ - i64::try_from(page.page_ordinal()).map_err(contract_number)?, - page.page_digest().as_str(), - page.cumulative_digest().as_str(), - i64::try_from(page.chunk_count()).map_err(contract_number)?, - i64::try_from(page.payload_bytes()).map_err(contract_number)?, - i64::try_from(page.import_count()).map_err(contract_number)?, - i64::try_from(page.import_payload_bytes()).map_err(contract_number)?, - page.next_cursor().import_dictionary_digest().as_str(), - cursor, + i64::try_from(page.page_ordinal).map_err(contract_number)?, + page.page_digest.as_str(), + page.cumulative_digest.as_str(), + i64::try_from(page.chunk_count).map_err(contract_number)?, + i64::try_from(page.payload_bytes).map_err(contract_number)?, + i64::try_from(page.import_count).map_err(contract_number)?, + i64::try_from(page.import_payload_bytes).map_err(contract_number)?, + page.import_dictionary_digest.as_str(), + page.next_cursor.as_slice(), ], ) .map_err(sqlite_error)?; @@ -1723,9 +2523,7 @@ fn advance_native_section_rows( let mut rows = statement.query(parameters).map_err(sqlite_error)?; let mut advanced = 0usize; while let Some(row) = rows.next().map_err(sqlite_error)? { - // Count the row before cancellation so rolled-back and interrupted - // work remains visible rather than masquerading as an idle wake. - record_finalization_row(); + // Cancellation remains bounded within every native-key scan. checkpoint(control)?; let key = native_row_key(section, row)?; if state @@ -2013,207 +2811,6 @@ fn verify_final_sections_against_source( Ok(()) } -fn append_imports( - transaction: &Transaction<'_>, - page: &VerifiedSealedLexicalPageV1, - control: &dyn CodeIndexExecutionControlV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { - for evidence in page.imports() { - checkpoint(control)?; - let canonical = serde_json::to_vec(evidence) - .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; - transaction - .execute( - "INSERT INTO import_evidence(canonical, evidence) VALUES (?1, ?1)", - params![canonical], - ) - .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; - let digest = import_integrity_digest(&canonical, &canonical)?; - transaction - .execute( - "INSERT INTO import_integrity(canonical, digest) VALUES (?1, ?2)", - params![canonical, digest.as_str()], - ) - .map_err(sqlite_error)?; - } - Ok(()) -} - -fn append_page_rows( - transaction: &Transaction<'_>, - metadata: &CodeLexicalProjectionMetadataV1, - first_document: u64, - page: &VerifiedSealedLexicalPageV1, - control: &dyn CodeIndexExecutionControlV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { - // The verified persisted cursor is the exact row-count authority. Using - // it avoids a full table COUNT on every page while preserving contiguous - // document IDs across restarts and replay. - let mut document = i64::try_from(first_document).map_err(contract_number)?; - for admitted in page.chunks() { - checkpoint(control)?; - u32::try_from(document).map_err(|_| { - CodeLexicalArtifactErrorV1::Contract( - "lexical artifact exceeds the posting document-id range".to_owned(), - ) - })?; - let chunk = admitted.chunk(); - if chunk.anchor.generation_id != metadata.generation { - return Err(CodeLexicalArtifactErrorV1::Contract( - "sealed lexical page contains a foreign generation".to_owned(), - )); - } - let logical_path = metadata - .logical_paths - .get(&chunk.anchor.file_occurrence_id) - .cloned() - .ok_or_else(|| { - CodeLexicalArtifactErrorV1::Contract(format!( - "lexical artifact metadata is missing path {}", - chunk.anchor.file_occurrence_id - )) - })?; - let (row, fields) = ProjectedChunkV1::from_ref(chunk, logical_path); - insert_fields(transaction, document, &fields)?; - insert_exact(transaction, document, &row)?; - insert_document_ngrams( - transaction, - NGRAM_NORMALIZED, - document, - row.normalized_text.as_bytes(), - control, - )?; - if row.sanitized_text.as_str().as_bytes() != row.normalized_text.as_bytes() { - insert_document_ngrams( - transaction, - NGRAM_RAW_OVERRIDE, - document, - row.sanitized_text.as_str().as_bytes(), - control, - )?; - } - let artifact_row = ArtifactRowV1::from(row); - let bytes = serde_json::to_vec(&artifact_row) - .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; - transaction - .execute( - "INSERT INTO rows(document_id, chunk_id, row) VALUES (?1, ?2, ?3)", - params![document, artifact_row.id.as_str(), bytes], - ) - .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; - let digest = document_integrity_digest(transaction, document, control)?; - transaction - .execute( - "INSERT INTO document_integrity(document_id, digest) VALUES (?1, ?2)", - params![document, digest.as_str()], - ) - .map_err(sqlite_error)?; - document = document.checked_add(1).ok_or_else(|| { - CodeLexicalArtifactErrorV1::Contract( - "lexical artifact document id overflowed".to_owned(), - ) - })?; - } - Ok(()) -} - -fn insert_fields( - transaction: &Transaction<'_>, - document: i64, - fields: &BTreeMap>, -) -> Result<(), CodeLexicalArtifactErrorV1> { - let mut field_stats = transaction - .prepare_cached( - "INSERT INTO field_stats(field, total_length) VALUES (?1, ?2) ON CONFLICT(field) DO UPDATE SET total_length = total_length + excluded.total_length", - ) - .map_err(sqlite_error)?; - let mut term_postings = transaction - .prepare_cached( - "INSERT INTO term_postings(field, term, document_id, frequency) VALUES (?1, ?2, ?3, ?4)", - ) - .map_err(sqlite_error)?; - let mut term_stats = transaction - .prepare_cached( - "INSERT INTO term_stats(field, term, document_frequency) VALUES (?1, ?2, 1) ON CONFLICT(field, term) DO UPDATE SET document_frequency = document_frequency + 1", - ) - .map_err(sqlite_error)?; - let mut vocabulary = transaction - .prepare_cached("INSERT OR IGNORE INTO vocabulary(term) VALUES (?1)") - .map_err(sqlite_error)?; - for (field, terms) in fields { - let encoded_field = encode_field(*field)?; - field_stats - .execute(params![ - encoded_field, - i64::try_from(terms.len()).map_err(contract_number)? - ]) - .map_err(sqlite_error)?; - let mut frequencies = BTreeMap::<&str, u32>::new(); - for term in terms { - frequencies - .entry(term) - .and_modify(|frequency| *frequency = frequency.saturating_add(1)) - .or_insert(1); - } - for (term, frequency) in frequencies { - term_postings - .execute(params![encoded_field, term, document, i64::from(frequency)]) - .map_err(sqlite_error)?; - term_stats - .execute(params![encoded_field, term]) - .map_err(sqlite_error)?; - if *field != LexicalFieldV1::Subtoken { - vocabulary.execute([term]).map_err(sqlite_error)?; - } - } - } - Ok(()) -} - -fn insert_exact( - transaction: &Transaction<'_>, - document: i64, - row: &ProjectedChunkV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { - insert_exact_posting( - transaction, - &encode_exact_field(ExactFieldV1::Path)?, - Cow::Borrowed(row.logical_path.as_bytes()), - document, - )?; - let mut encoded_fields = BTreeMap::new(); - for term in &row.exact_terms { - let field = exact_field_for_kind(term.kind()); - let encoded = match encoded_fields.entry(field) { - Entry::Vacant(slot) => &*slot.insert(encode_exact_field(field)?), - Entry::Occupied(slot) => slot.into_mut(), - }; - insert_exact_posting( - transaction, - encoded, - canonical_projected_exact_term(term), - document, - )?; - } - Ok(()) -} - -fn insert_exact_posting( - transaction: &Transaction<'_>, - field: &str, - term: Cow<'_, [u8]>, - document: i64, -) -> Result<(), CodeLexicalArtifactErrorV1> { - transaction - .prepare_cached( - "INSERT OR IGNORE INTO exact_postings(field, term, document_id) VALUES (?1, ?2, ?3)", - ) - .map_err(sqlite_error)? - .execute(params![field, term.as_ref(), document]) - .map_err(sqlite_error)?; - Ok(()) -} - fn document_integrity_digest( transaction: &Transaction<'_>, document: i64, @@ -2598,6 +3195,7 @@ fn verify_source_receipt( Ok(()) } +#[cfg(feature = "hotpath")] fn record_finalization_step(step: &CodeLexicalArtifactFinalizationStepV1) { match step { CodeLexicalArtifactFinalizationStepV1::Pending { completed_rows, .. } => { @@ -2614,7 +3212,148 @@ fn record_finalization_step(step: &CodeLexicalArtifactFinalizationStepV1) { } } -#[hotpath::measure(label = "query.artifact.verify")] +#[cfg(not(feature = "hotpath"))] +fn record_finalization_step(step: &CodeLexicalArtifactFinalizationStepV1) { + let _ = step; +} + +#[cfg(feature = "hotpath")] +fn record_batch_outcome( + result: &Result, +) { + match result { + Ok(_) => { + hotpath::gauge!("query.artifact.batch.outcome.committed_total").inc(1u64); + } + Err(CodeLexicalArtifactErrorV1::Interrupted(_)) => { + hotpath::gauge!("query.artifact.batch.outcome.interrupted_total").inc(1u64); + } + Err(_) => { + hotpath::gauge!("query.artifact.batch.outcome.failed_total").inc(1u64); + } + } +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_outcome( + result: &Result, +) { + let _ = result; +} + +#[cfg(feature = "hotpath")] +fn record_prepared_batch_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let documents = pages.iter().map(|page| page.documents.len()).sum::(); + let source_bytes = pages + .iter() + .map(PreparedCodeLexicalArtifactPageV1::source_retained_bytes) + .sum::(); + let prepared_bytes = pages + .iter() + .map(PreparedCodeLexicalArtifactPageV1::retained_owned_bytes) + .sum::(); + let effective_workers = tracedecay_code_index::parallelism::indexing_workers().min(pages.len()); + let mut scratch = pages + .iter() + .map(PreparedCodeLexicalArtifactPageV1::preparation_scratch_bytes) + .collect::>(); + scratch.sort_unstable_by(|left, right| right.cmp(left)); + let active_scratch = scratch.into_iter().take(effective_workers).sum::(); + hotpath::gauge!("query.artifact.batch.prepared_pages_total").inc(pages.len() as u64); + hotpath::gauge!("query.artifact.batch.prepared_documents_total").inc(documents as u64); + hotpath::gauge!("query.artifact.batch.source_bytes_total").inc(source_bytes as u64); + hotpath::gauge!("query.artifact.batch.prepared_bytes_total").inc(prepared_bytes as u64); + hotpath::gauge!("query.artifact.batch.active_scratch_bytes_total").inc(active_scratch as u64); + hotpath::gauge!("query.artifact.batch.effective_workers").set(effective_workers as u64); +} + +#[cfg(not(feature = "hotpath"))] +fn record_prepared_batch_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let _ = pages; +} + +#[cfg(feature = "hotpath")] +fn record_batch_import_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let imports = pages.iter().map(|page| page.imports.len()).sum::(); + hotpath::gauge!("query.artifact.batch.import_rows_total").inc(imports as u64); +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_import_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let _ = pages; +} + +#[cfg(feature = "hotpath")] +fn record_batch_posting_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let postings = pages + .iter() + .flat_map(|page| &page.documents) + .map(|document| { + document.term_postings.len() + + document.exact_postings.len() + + document.ngram_postings.len() + }) + .sum::(); + hotpath::gauge!("query.artifact.batch.posting_rows_total").inc(postings as u64); +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_posting_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let _ = pages; +} + +#[cfg(feature = "hotpath")] +fn record_batch_row_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let rows = pages.iter().map(|page| page.documents.len()).sum::(); + hotpath::gauge!("query.artifact.batch.document_rows_total").inc(rows as u64); +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_row_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let _ = pages; +} + +#[cfg(feature = "hotpath")] +fn record_batch_receipt_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + hotpath::gauge!("query.artifact.batch.receipt_rows_total").inc(pages.len() as u64); +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_receipt_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { + let _ = pages; +} + +#[cfg(feature = "hotpath")] +fn record_artifact_progress(progress: &CodeLexicalArtifactBuildProgressV1) { + hotpath::gauge!("query.artifact.pages").set(progress.next_page_ordinal); + hotpath::gauge!("query.artifact.rows").set(progress.completed_chunks); + hotpath::gauge!("query.artifact.bytes").set(progress.completed_payload_bytes); +} + +#[cfg(not(feature = "hotpath"))] +fn record_artifact_progress(progress: &CodeLexicalArtifactBuildProgressV1) { + let _ = progress; +} + +fn commit_finalization_transaction( + transaction: Transaction<'_>, + metrics: &mut FinalizationTransactionMetricsV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.finalization.commit_attempts_total").inc(1u64); + let result = hotpath::measure_block!( + "query.artifact.finalization.commit", + transaction.commit().map_err(sqlite_error) + ); + if result.is_ok() { + metrics.mark_committed(); + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.finalization.commit_succeeded_total").inc(1u64); + } + result +} + +#[hotpath::measure(label = "query.artifact.finalization.sealed_verify")] fn verify_finalized_artifact( connection: &Connection, path: &Path, diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs index f0904b2115..d07561ea4e 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/postings.rs @@ -1,11 +1,8 @@ use std::collections::BTreeSet; -use rusqlite::{Transaction, params}; use tracedecay_code_index::production::CodeIndexExecutionControlV1; -use super::{ - ARTIFACT_DOCUMENT_SCRATCH_LIMIT_BYTES, CodeLexicalArtifactErrorV1, checkpoint, sqlite_error, -}; +use super::{ARTIFACT_DOCUMENT_SCRATCH_LIMIT_BYTES, CodeLexicalArtifactErrorV1, checkpoint}; pub(super) const NGRAM_NORMALIZED: i64 = 0; pub(super) const NGRAM_RAW_OVERRIDE: i64 = 1; @@ -60,13 +57,11 @@ fn reserve_ngram_scratch( Ok(scratch) } -pub(super) fn insert_document_ngrams( - transaction: &Transaction<'_>, - kind: i64, - document: i64, +/// Canonical bounded value projection for out-of-transaction preparation. +pub(super) fn document_ngrams( bytes: &[u8], control: &dyn CodeIndexExecutionControlV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { +) -> Result, CodeLexicalArtifactErrorV1> { let (_, scratch_bytes) = document_ngram_scratch(bytes.len())?; if scratch_bytes > ARTIFACT_DOCUMENT_SCRATCH_LIMIT_BYTES { return Err(CodeLexicalArtifactErrorV1::Contract(format!( @@ -89,23 +84,23 @@ pub(super) fn insert_document_ngrams( "lexical n-gram allocator retained {allocated_bytes} bytes beyond the {scratch_bytes}-byte scratch authority" ))); } + let mut observed = 0usize; for width in 1..=bytes.len().min(3) { - ngrams.extend(bytes.windows(width).map(pack_byte_ngram)); + for window in bytes.windows(width) { + if observed.is_multiple_of(4_096) { + checkpoint(control)?; + } + ngrams.push(pack_byte_ngram(window)); + observed = observed.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact n-gram work count overflowed".to_owned(), + ) + })?; + } } ngrams.sort_unstable(); ngrams.dedup(); - let mut statement = transaction - .prepare("INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (?1, ?2, ?3)") - .map_err(sqlite_error)?; - for (ordinal, ngram) in ngrams.into_iter().enumerate() { - if ordinal % 4_096 == 0 { - checkpoint(control)?; - } - statement - .execute(params![kind, i64::from(ngram), document]) - .map_err(sqlite_error)?; - } - Ok(()) + Ok(ngrams) } pub(super) fn query_ngrams(bytes: &[u8]) -> BTreeSet { diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs new file mode 100644 index 0000000000..a4ed62afc6 --- /dev/null +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs @@ -0,0 +1,589 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use sha2::{Digest, Sha256}; +use tracedecay_code_index::production::{CodeIndexExecutionControlV1, VerifiedSealedLexicalPageV1}; +use tracedecay_domain::{ExactFieldV1, ManifestDigest}; + +use super::format::{ArtifactRowV1, encode_exact_field, encode_field}; +use super::postings::{NGRAM_NORMALIZED, NGRAM_RAW_OVERRIDE, document_ngrams}; +use super::{CodeLexicalArtifactErrorV1, checkpoint}; +use crate::retrieval::lexical::LexicalFieldV1; + +use super::super::{ + CodeLexicalProjectionMetadataV1, ProjectedChunkV1, canonical_projected_exact_term, + exact_field_for_kind, +}; + +#[derive(Debug)] +pub struct PreparedCodeLexicalArtifactPageV1 { + pub(super) page_ordinal: u64, + pub(super) page_digest: ManifestDigest, + pub(super) cumulative_digest: ManifestDigest, + pub(super) chunk_count: u64, + pub(super) payload_bytes: u64, + pub(super) import_count: u64, + pub(super) import_payload_bytes: u64, + pub(super) import_dictionary_digest: ManifestDigest, + pub(super) previous_cursor: Option>, + pub(super) next_cursor: Vec, + pub(super) imports: Vec, + pub(super) documents: Vec, + source_retained_bytes: usize, + prepared_retained_bytes: usize, + preparation_scratch_bytes: usize, + estimated_write_rows: usize, + estimated_write_bytes: usize, +} + +impl PreparedCodeLexicalArtifactPageV1 { + pub fn page_ordinal(&self) -> u64 { + self.page_ordinal + } + + pub fn chunk_count(&self) -> u64 { + self.chunk_count + } + + pub fn payload_bytes(&self) -> u64 { + self.payload_bytes + } + + pub fn source_retained_bytes(&self) -> usize { + self.source_retained_bytes + } + + pub fn retained_owned_bytes(&self) -> usize { + self.prepared_retained_bytes + } + + pub fn preparation_scratch_bytes(&self) -> usize { + self.preparation_scratch_bytes + } + + pub fn estimated_write_rows(&self) -> usize { + self.estimated_write_rows + } + + pub fn estimated_write_bytes(&self) -> usize { + self.estimated_write_bytes + } + + pub fn ledger_charge_bytes(&self) -> Result { + self.source_retained_bytes + .checked_add(self.prepared_retained_bytes) + .and_then(|bytes| bytes.checked_add(self.preparation_scratch_bytes)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical page ledger charge overflowed".to_owned(), + ) + }) + } +} + +#[derive(Debug)] +pub(super) struct PreparedImportV1 { + pub(super) canonical: Vec, + pub(super) integrity_digest: ManifestDigest, +} + +#[derive(Debug)] +pub(super) struct PreparedDocumentV1 { + pub(super) document_id: i64, + pub(super) chunk_id: String, + pub(super) row: Vec, + pub(super) field_stats: Vec<(String, i64)>, + pub(super) term_postings: Vec, + pub(super) exact_postings: Vec<(String, Vec)>, + pub(super) ngram_postings: Vec<(i64, i64)>, + pub(super) integrity_digest: ManifestDigest, +} + +#[derive(Debug)] +pub(super) struct PreparedTermPostingV1 { + pub(super) field: String, + pub(super) term: String, + pub(super) frequency: i64, + pub(super) vocabulary: bool, +} + +pub(super) fn prepare_page( + metadata: &CodeLexicalProjectionMetadataV1, + page: &VerifiedSealedLexicalPageV1, + previous_cursor: Option>, + preparation_scratch_bytes: usize, + control: &dyn CodeIndexExecutionControlV1, +) -> Result { + checkpoint(control)?; + let first_document = page + .next_cursor() + .emitted_chunks() + .checked_sub(page.chunk_count()) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "sealed lexical page cursor regressed its chunk count".to_owned(), + ) + })?; + let mut documents = Vec::with_capacity(page.chunks().len()); + for (offset, admitted) in page.chunks().iter().enumerate() { + checkpoint(control)?; + let document = first_document + .checked_add(u64::try_from(offset).map_err(contract_number)?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared document id overflowed".to_owned(), + ) + })?; + documents.push(prepare_document( + metadata, + i64::try_from(document).map_err(contract_number)?, + admitted.chunk(), + control, + )?); + } + let mut imports = Vec::with_capacity(page.imports().len()); + for evidence in page.imports() { + checkpoint(control)?; + let canonical = serde_json::to_vec(evidence) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + let integrity_digest = import_integrity_digest(&canonical, &canonical)?; + imports.push(PreparedImportV1 { + canonical, + integrity_digest, + }); + } + let next_cursor = page + .next_cursor() + .persisted_bytes() + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + let mut prepared = PreparedCodeLexicalArtifactPageV1 { + page_ordinal: page.page_ordinal(), + page_digest: page.page_digest().clone(), + cumulative_digest: page.cumulative_digest().clone(), + chunk_count: page.chunk_count(), + payload_bytes: page.payload_bytes(), + import_count: page.import_count(), + import_payload_bytes: page.import_payload_bytes(), + import_dictionary_digest: page.next_cursor().import_dictionary_digest().clone(), + previous_cursor, + next_cursor, + imports, + documents, + source_retained_bytes: page.retained_owned_bytes(), + prepared_retained_bytes: 0, + preparation_scratch_bytes, + estimated_write_rows: 0, + estimated_write_bytes: 0, + }; + prepared.prepared_retained_bytes = prepared_retained_bytes(&prepared)?; + ( + prepared.estimated_write_rows, + prepared.estimated_write_bytes, + ) = estimated_sqlite_writes(&prepared)?; + Ok(prepared) +} + +fn prepare_document( + metadata: &CodeLexicalProjectionMetadataV1, + document_id: i64, + chunk: &tracedecay_domain::CodeSearchChunkV1, + control: &dyn CodeIndexExecutionControlV1, +) -> Result { + u32::try_from(document_id).map_err(|_| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact exceeds the posting document-id range".to_owned(), + ) + })?; + if chunk.anchor.generation_id != metadata.generation { + return Err(CodeLexicalArtifactErrorV1::Contract( + "sealed lexical page contains a foreign generation".to_owned(), + )); + } + let logical_path = metadata + .logical_paths + .get(&chunk.anchor.file_occurrence_id) + .cloned() + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract(format!( + "lexical artifact metadata is missing path {}", + chunk.anchor.file_occurrence_id + )) + })?; + let (row, fields) = ProjectedChunkV1::from_ref(chunk, logical_path); + let mut field_stats = Vec::with_capacity(fields.len()); + let mut term_postings = Vec::new(); + for (field, terms) in &fields { + checkpoint(control)?; + let encoded_field = encode_field(*field)?; + field_stats.push(( + encoded_field.clone(), + i64::try_from(terms.len()).map_err(contract_number)?, + )); + let mut frequencies = BTreeMap::<&str, u32>::new(); + for term in terms { + frequencies + .entry(term) + .and_modify(|frequency| *frequency = frequency.saturating_add(1)) + .or_insert(1); + } + for (term, frequency) in frequencies { + term_postings.push(PreparedTermPostingV1 { + field: encoded_field.clone(), + term: term.to_owned(), + frequency: i64::from(frequency), + vocabulary: *field != LexicalFieldV1::Subtoken, + }); + } + } + field_stats.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + term_postings.sort_unstable_by(|left, right| { + (&left.field, &left.term).cmp(&(&right.field, &right.term)) + }); + + let mut exact_postings = BTreeSet::new(); + exact_postings.insert(( + encode_exact_field(ExactFieldV1::Path)?, + row.logical_path.as_bytes().to_vec(), + )); + let mut encoded_fields = BTreeMap::new(); + for term in &row.exact_terms { + let field = exact_field_for_kind(term.kind()); + let encoded = match encoded_fields.entry(field) { + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(encode_exact_field(field)?) + } + std::collections::btree_map::Entry::Occupied(slot) => slot.into_mut(), + }; + exact_postings.insert(( + encoded.clone(), + canonical_projected_exact_term(term).into_owned(), + )); + } + + let mut ngram_postings = document_ngrams(row.normalized_text.as_bytes(), control)? + .into_iter() + .map(|ngram| (NGRAM_NORMALIZED, i64::from(ngram))) + .collect::>(); + if row.sanitized_text.as_str().as_bytes() != row.normalized_text.as_bytes() { + ngram_postings.extend( + document_ngrams(row.sanitized_text.as_str().as_bytes(), control)? + .into_iter() + .map(|ngram| (NGRAM_RAW_OVERRIDE, i64::from(ngram))), + ); + } + let artifact_row = ArtifactRowV1::from(row); + let chunk_id = artifact_row.id.as_str().to_owned(); + let row = serde_json::to_vec(&artifact_row) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + let exact_postings = exact_postings.into_iter().collect::>(); + let integrity_digest = document_integrity_digest( + document_id, + &row, + &term_postings, + &exact_postings, + &ngram_postings, + )?; + Ok(PreparedDocumentV1 { + document_id, + chunk_id, + row, + field_stats, + term_postings, + exact_postings, + ngram_postings, + integrity_digest, + }) +} + +fn document_integrity_digest( + document: i64, + row: &[u8], + term_postings: &[PreparedTermPostingV1], + exact_postings: &[(String, Vec)], + ngram_postings: &[(i64, i64)], +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v1\0"); + hasher.update(document.to_le_bytes()); + hash_table(&mut hasher, "row", 1, |hasher, _| hash_blob(hasher, row))?; + hash_table( + &mut hasher, + "term_posting", + term_postings.len(), + |hasher, ordinal| { + let posting = &term_postings[ordinal]; + hash_text(hasher, posting.field.as_bytes())?; + hash_text(hasher, posting.term.as_bytes())?; + hash_integer(hasher, posting.frequency); + Ok(()) + }, + )?; + hash_table( + &mut hasher, + "exact_posting", + exact_postings.len(), + |hasher, ordinal| { + let (field, term) = &exact_postings[ordinal]; + hash_text(hasher, field.as_bytes())?; + hash_blob(hasher, term) + }, + )?; + hash_table( + &mut hasher, + "ngram_posting", + ngram_postings.len(), + |hasher, ordinal| { + let (kind, ngram) = ngram_postings[ordinal]; + hash_integer(hasher, kind); + hash_integer(hasher, ngram); + Ok(()) + }, + )?; + integrity_digest(hasher) +} + +fn hash_table( + hasher: &mut Sha256, + table: &str, + row_count: usize, + mut hash_row: impl FnMut(&mut Sha256, usize) -> Result<(), CodeLexicalArtifactErrorV1>, +) -> Result<(), CodeLexicalArtifactErrorV1> { + hasher.update( + u64::try_from(table.len()) + .map_err(contract_number)? + .to_le_bytes(), + ); + hasher.update(table.as_bytes()); + for ordinal in 0..row_count { + hasher.update(b"row\0"); + hash_row(hasher, ordinal)?; + } + hasher.update(b"end\0"); + hasher.update( + u64::try_from(row_count) + .map_err(contract_number)? + .to_le_bytes(), + ); + Ok(()) +} + +fn hash_integer(hasher: &mut Sha256, value: i64) { + hasher.update([1]); + hasher.update(value.to_le_bytes()); +} + +fn hash_text(hasher: &mut Sha256, value: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { + hasher.update([3]); + hash_bytes(hasher, value) +} + +fn hash_blob(hasher: &mut Sha256, value: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { + hasher.update([4]); + hash_bytes(hasher, value) +} + +fn hash_bytes(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { + hasher.update( + u64::try_from(bytes.len()) + .map_err(contract_number)? + .to_le_bytes(), + ); + hasher.update(bytes); + Ok(()) +} + +fn import_integrity_digest( + canonical: &[u8], + evidence: &[u8], +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-derived-import.v1\0"); + hash_bytes(&mut hasher, canonical)?; + hash_bytes(&mut hasher, evidence)?; + integrity_digest(hasher) +} + +fn integrity_digest(hasher: Sha256) -> Result { + ManifestDigest::from_sha256_bytes(&hasher.finalize()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string())) +} + +fn prepared_retained_bytes( + page: &PreparedCodeLexicalArtifactPageV1, +) -> Result { + let mut bytes = page + .page_digest + .as_str() + .len() + .checked_add(page.cumulative_digest.as_str().len()) + .and_then(|bytes| bytes.checked_add(page.import_dictionary_digest.as_str().len())) + .and_then(|bytes| bytes.checked_add(page.next_cursor.capacity())) + .and_then(|bytes| bytes.checked_add(page.previous_cursor.as_ref().map_or(0, Vec::capacity))) + .and_then(|bytes| { + bytes.checked_add( + page.imports + .capacity() + .saturating_mul(std::mem::size_of::()), + ) + }) + .and_then(|bytes| { + bytes.checked_add( + page.documents + .capacity() + .saturating_mul(std::mem::size_of::()), + ) + }) + .ok_or_else(prepared_charge_overflow)?; + for import in &page.imports { + bytes = bytes + .checked_add(import.canonical.capacity()) + .and_then(|bytes| bytes.checked_add(import.integrity_digest.as_str().len())) + .ok_or_else(prepared_charge_overflow)?; + } + for document in &page.documents { + bytes = bytes + .checked_add(document.chunk_id.capacity()) + .and_then(|bytes| bytes.checked_add(document.row.capacity())) + .and_then(|bytes| bytes.checked_add(document.integrity_digest.as_str().len())) + .and_then(|bytes| { + bytes.checked_add( + document + .field_stats + .capacity() + .saturating_mul(std::mem::size_of::<(String, i64)>()), + ) + }) + .and_then(|bytes| { + bytes.checked_add( + document + .term_postings + .capacity() + .saturating_mul(std::mem::size_of::()), + ) + }) + .and_then(|bytes| { + bytes.checked_add( + document + .exact_postings + .capacity() + .saturating_mul(std::mem::size_of::<(String, Vec)>()), + ) + }) + .and_then(|bytes| { + bytes.checked_add( + document + .ngram_postings + .capacity() + .saturating_mul(std::mem::size_of::<(i64, i64)>()), + ) + }) + .ok_or_else(prepared_charge_overflow)?; + for (field, _) in &document.field_stats { + bytes = bytes + .checked_add(field.capacity()) + .ok_or_else(prepared_charge_overflow)?; + } + for posting in &document.term_postings { + bytes = bytes + .checked_add(posting.field.capacity()) + .and_then(|bytes| bytes.checked_add(posting.term.capacity())) + .ok_or_else(prepared_charge_overflow)?; + } + for (field, term) in &document.exact_postings { + bytes = bytes + .checked_add(field.capacity()) + .and_then(|bytes| bytes.checked_add(term.capacity())) + .ok_or_else(prepared_charge_overflow)?; + } + } + Ok(bytes) +} + +fn estimated_sqlite_writes( + page: &PreparedCodeLexicalArtifactPageV1, +) -> Result<(usize, usize), CodeLexicalArtifactErrorV1> { + let mut rows = 1usize; + let mut bytes = page + .page_digest + .as_str() + .len() + .checked_add(page.cumulative_digest.as_str().len()) + .and_then(|bytes| bytes.checked_add(page.import_dictionary_digest.as_str().len())) + .and_then(|bytes| bytes.checked_add(page.next_cursor.len())) + .ok_or_else(prepared_write_overflow)?; + for import in &page.imports { + rows = rows.checked_add(2).ok_or_else(prepared_write_overflow)?; + bytes = bytes + .checked_add(import.canonical.len().saturating_mul(2)) + .and_then(|bytes| bytes.checked_add(import.integrity_digest.as_str().len())) + .ok_or_else(prepared_write_overflow)?; + } + for document in &page.documents { + rows = rows.checked_add(2).ok_or_else(prepared_write_overflow)?; + bytes = bytes + .checked_add(document.chunk_id.len()) + .and_then(|bytes| bytes.checked_add(document.row.len())) + .and_then(|bytes| bytes.checked_add(document.integrity_digest.as_str().len())) + .ok_or_else(prepared_write_overflow)?; + for (field, _) in &document.field_stats { + rows = rows.checked_add(1).ok_or_else(prepared_write_overflow)?; + bytes = bytes + .checked_add(field.len()) + .and_then(|bytes| bytes.checked_add(16)) + .ok_or_else(prepared_write_overflow)?; + } + for posting in &document.term_postings { + // Posting, term statistic, and optional vocabulary mutation. + rows = rows + .checked_add(2 + usize::from(posting.vocabulary)) + .ok_or_else(prepared_write_overflow)?; + bytes = bytes + .checked_add( + posting + .field + .len() + .saturating_add(posting.term.len()) + .saturating_mul(2), + ) + .and_then(|bytes| { + bytes.checked_add(if posting.vocabulary { + posting.term.len() + } else { + 0 + }) + }) + .and_then(|bytes| bytes.checked_add(32)) + .ok_or_else(prepared_write_overflow)?; + } + for (field, term) in &document.exact_postings { + rows = rows.checked_add(1).ok_or_else(prepared_write_overflow)?; + bytes = bytes + .checked_add(field.len()) + .and_then(|bytes| bytes.checked_add(term.len())) + .and_then(|bytes| bytes.checked_add(8)) + .ok_or_else(prepared_write_overflow)?; + } + rows = rows + .checked_add(document.ngram_postings.len()) + .ok_or_else(prepared_write_overflow)?; + bytes = bytes + .checked_add(document.ngram_postings.len().saturating_mul(24)) + .ok_or_else(prepared_write_overflow)?; + } + Ok((rows, bytes)) +} + +fn prepared_write_overflow() -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical page estimated SQLite write overflowed".to_owned(), + ) +} + +fn prepared_charge_overflow() -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::Contract( + "prepared lexical page retained-byte charge overflowed".to_owned(), + ) +} + +fn contract_number(error: impl std::fmt::Display) -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::Contract(error.to_string()) +} diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs index 67eec968d0..81fb9c5c52 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; -use std::sync::{Arc, Mutex as StdMutex}; +use std::sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard}; #[cfg(any(test, feature = "hotpath"))] use rusqlite::StatementStatus; @@ -59,16 +59,8 @@ pub struct CodeLexicalArtifactReaderV1 { retained_owned_bytes: usize, } -#[cfg(feature = "hotpath")] -type ArtifactConnectionMutex = hotpath::mutexes::Mutex; -#[cfg(not(feature = "hotpath"))] type ArtifactConnectionMutex = StdMutex; -#[cfg(feature = "hotpath")] -type ArtifactConnectionMutexGuard<'a, T> = hotpath::mutexes::MutexGuard<'a, T>; -#[cfg(not(feature = "hotpath"))] -type ArtifactConnectionMutexGuard<'a, T> = std::sync::MutexGuard<'a, T>; - impl std::fmt::Debug for CodeLexicalArtifactReaderV1 { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter @@ -321,14 +313,11 @@ impl CodeLexicalArtifactReaderV1 { checkpoint(control)?; let retained_owned_bytes = stored_metadata_bytes.len().saturating_add(page_cache_bytes); Ok(Self { - // Every clone shares one rusqlite handle. The instrumented mutex - // exposes both shared-reader coordination wait and the full - // serialized SQL hold rather than attributing that time to the - // outer lane alone. This is one handle, not a connection pool. - connection: Arc::new(hotpath::mutex!( - StdMutex::new(connection), - label = "query.artifact.connection" - )), + // Every clone shares one rusqlite handle. Readers are replaced on + // remount, while Hotpath 0.24 retains every instrumented mutex + // identity for the process lifetime, so this per-reader lock must + // remain plain. Static query spans retain operation visibility. + connection: Arc::new(StdMutex::new(connection)), metadata, receipt: stored, retained_owned_bytes, @@ -432,9 +421,7 @@ impl CodeLexicalArtifactReaderV1 { } } - fn lock_connection( - &self, - ) -> Result, CodeLexicalArtifactErrorV1> { + fn lock_connection(&self) -> Result, CodeLexicalArtifactErrorV1> { self.connection.lock().map_err(|_| { CodeLexicalArtifactErrorV1::Io("lexical artifact reader lock is poisoned".to_owned()) }) @@ -2005,7 +1992,8 @@ fn map_query_artifact_error(error: CodeLexicalArtifactErrorV1) -> RetrievalPortE CodeLexicalArtifactErrorV1::Interrupted(_) => RetrievalPortError::Cancelled, CodeLexicalArtifactErrorV1::Incompatible(_) => RetrievalPortError::IncompatibleProjection, CodeLexicalArtifactErrorV1::Contract(error) => RetrievalPortError::Contract(error), - CodeLexicalArtifactErrorV1::Unreserved(_) => RetrievalPortError::BudgetExceeded, + CodeLexicalArtifactErrorV1::Unreserved(_) + | CodeLexicalArtifactErrorV1::BatchTooLarge { .. } => RetrievalPortError::BudgetExceeded, CodeLexicalArtifactErrorV1::Corrupt(error) | CodeLexicalArtifactErrorV1::Io(error) | CodeLexicalArtifactErrorV1::Missing(error) => { @@ -2019,6 +2007,7 @@ mod tests { use std::cmp::Reverse; use std::collections::{BTreeSet, BinaryHeap}; use std::path::PathBuf; + use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicUsize, Ordering}; use rusqlite::{Connection, params}; @@ -2027,7 +2016,7 @@ mod tests { use super::{ ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1, ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1, - ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, ArtifactQueryMetricsV1, + ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, ArtifactConnectionMutex, ArtifactQueryMetricsV1, CodeLexicalArtifactErrorV1, CodeLexicalArtifactReaderV1, DocumentQueryV1, NGRAM_NORMALIZED, map_query_artifact_error, ngram_document_query, query_ngrams, retain_bounded, union_document_queries, visit_document_ids, visit_lexical_rows, @@ -2093,6 +2082,23 @@ mod tests { documents } + #[cfg(feature = "hotpath")] + #[test] + fn repeated_feature_on_reader_connections_use_plain_mutexes_and_preserve_queries() { + for expected in 0..16i64 { + let connection: ArtifactConnectionMutex = + StdMutex::new(Connection::open_in_memory().expect("in-memory SQLite")); + + let value = connection + .lock() + .expect("reader connection lock") + .query_row("SELECT ?1", [expected], |row| row.get::<_, i64>(0)) + .expect("query through reader connection lock"); + + assert_eq!(value, expected); + } + } + #[test] fn invalid_content_addressed_budget_is_rejected_before_path_touch() { let missing = std::env::temp_dir().join(format!( diff --git a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs index 21727513b9..a67d6c60c4 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -18,7 +18,8 @@ use tracedecay_code_index::production::{ CodeIndexExecutionControlV1, CodeIndexGenerationScopeV1, CodeIndexInterruptionV1, CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, - CodeIndexRepositoryParseIdentityV1, VerifiedSealedLexicalPageReadV1, + CodeIndexRepositoryParseIdentityV1, VerifiedSealedLexicalPageBatchBoundsV1, + VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, VerifiedSealedLexicalSourceReceiptV1, }; @@ -486,6 +487,125 @@ fn real_verified_pages() -> ( real_verified_pages_with_maximum_page_chunks(128) } +fn page_batch_identities(pages: &[VerifiedSealedLexicalPageV1]) -> Vec<(u64, String, Vec)> { + pages + .iter() + .map(|page| { + ( + page.page_ordinal(), + page.page_digest().as_str().to_owned(), + page.next_cursor() + .persisted_bytes() + .expect("persist page cursor"), + ) + }) + .collect() +} + +#[test] +fn sealed_source_rejected_batch_retries_byte_identical_pages_and_cursor() { + let fixture = real_lexical_source_fixture(); + let control = ArtifactControl { cancelled: false }; + let mut source = fixture.open_source(1); + let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new(2, 64 * 1024 * 1024) + .expect("two-page batch bounds"); + let cursor_before = source.cursor().persisted_bytes().expect("initial cursor"); + let mut rejected_identities = None; + let rejected = source + .next_page_batch_if(&control, bounds, |pages| { + rejected_identities = Some(page_batch_identities(pages)); + Err("reject staged batch") + }) + .expect("stage rejected batch"); + assert!(matches!(rejected, Err("reject staged batch"))); + assert_eq!( + source.cursor().persisted_bytes().expect("rejected cursor"), + cursor_before, + "callback rejection must retain the byte-exact source cursor" + ); + + let retried = source + .next_page_batch_if(&control, bounds, |_| Ok::<(), &'static str>(())) + .expect("retry staged batch") + .expect("accept retried batch"); + let VerifiedSealedLexicalPageBatchReadV1::Pages(retried_pages) = retried else { + panic!("fixture must emit a retried page batch"); + }; + assert_eq!( + page_batch_identities(&retried_pages), + rejected_identities.expect("rejected page identities"), + "retry must reproduce the exact ordered pages" + ); + assert_eq!( + source.cursor().persisted_bytes().expect("accepted cursor"), + retried_pages + .last() + .expect("retried pages") + .next_cursor() + .persisted_bytes() + .expect("final accepted cursor"), + "acceptance advances exactly to the final page" + ); +} + +#[test] +fn sealed_source_batch_bounds_and_completion_never_advance_empty_work() { + assert!(VerifiedSealedLexicalPageBatchBoundsV1::new(0, 1).is_err()); + assert!(VerifiedSealedLexicalPageBatchBoundsV1::new(1, 0).is_err()); + + let fixture = real_lexical_source_fixture(); + let control = ArtifactControl { cancelled: false }; + let (pages, _) = drain_verified_pages(&fixture, 1); + let first_page_bound = + std::mem::size_of::() + pages[0].retained_owned_bytes() - 1; + let too_small = VerifiedSealedLexicalPageBatchBoundsV1::new(1, first_page_bound) + .expect("sub-page batch bound"); + let mut source = fixture.open_source(1); + let cursor_before = source.cursor().persisted_bytes().expect("initial cursor"); + let callbacks = AtomicUsize::new(0); + assert!( + source + .next_page_batch_if(&control, too_small, |_| { + callbacks.fetch_add(1, Ordering::SeqCst); + Ok::<(), ()>(()) + }) + .is_err(), + "a first page above the retained-byte bound is a typed source error" + ); + assert_eq!(callbacks.load(Ordering::SeqCst), 0); + assert_eq!( + source.cursor().persisted_bytes().expect("refused cursor"), + cursor_before + ); + + let bounds = + VerifiedSealedLexicalPageBatchBoundsV1::new(2, 64 * 1024 * 1024).expect("drain bounds"); + loop { + let before = callbacks.load(Ordering::SeqCst); + let read = source + .next_page_batch_if(&control, bounds, |_| { + callbacks.fetch_add(1, Ordering::SeqCst); + Ok::<(), ()>(()) + }) + .expect("drain source") + .expect("accept source batch"); + match read { + VerifiedSealedLexicalPageBatchReadV1::Pages(pages) => { + assert!(!pages.is_empty(), "page batches are never empty"); + assert_eq!(callbacks.load(Ordering::SeqCst), before + 1); + } + VerifiedSealedLexicalPageBatchReadV1::Complete(_) => { + assert_eq!( + callbacks.load(Ordering::SeqCst), + before, + "completion must bypass the page callback" + ); + break; + } + } + } +} + fn finish_staged_artifact( builder: &mut CodeLexicalArtifactBuilderV1, source_receipt: &VerifiedSealedLexicalSourceReceiptV1, @@ -518,14 +638,19 @@ impl TestArtifactSourceStaging for CodeLexicalArtifactBuilderV1 { source: &mut VerifiedSealedLexicalPageSourceV1, control: &dyn CodeIndexExecutionControlV1, ) -> Result { + let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new(16, 32 * 1024 * 1024) + .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; let receipt = loop { let staged_pages = self.progress()?.next_page_ordinal; let admitted = source - .next_page_if(control, |page| { - if page.page_ordinal() < staged_pages { + .next_page_batch_if(control, bounds, |pages| { + if pages + .last() + .is_some_and(|page| page.page_ordinal() < staged_pages) + { Ok(()) } else { - self.append_page(page, control).map(|_| ()) + self.append_pages(pages, control).map(|_| ()) } }) .map_err(|error| match error { @@ -535,8 +660,8 @@ impl TestArtifactSourceStaging for CodeLexicalArtifactBuilderV1 { error => CodeLexicalArtifactErrorV1::Corrupt(error.to_string()), })?; match admitted? { - VerifiedSealedLexicalPageReadV1::Page(_) => {} - VerifiedSealedLexicalPageReadV1::Complete(receipt) => break receipt, + VerifiedSealedLexicalPageBatchReadV1::Pages(_) => {} + VerifiedSealedLexicalPageBatchReadV1::Complete(receipt) => break receipt, } }; Ok(finish_staged_artifact(self, &receipt, control)) @@ -1817,6 +1942,231 @@ fn staged_row_cardinality(artifact_path: &Path) -> (u64, u64) { ) } +#[test] +fn disk_artifact_multi_page_append_rolls_back_the_whole_fresh_suffix() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + assert!(pages.len() >= 2, "fixture must emit a multi-page batch"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("atomic-page-batch.sqlite"); + let control = ArtifactControl { cancelled: false }; + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) + .expect("create artifact"); + + // Force the second page's document insert to fail after the first page + // has already performed relational writes in the same transaction. + let second_document = i64::try_from(pages[0].chunk_count()).expect("first page document count"); + let connection = rusqlite::Connection::open(&artifact_path).expect("open failure fixture"); + connection + .execute( + "INSERT INTO rows(document_id, chunk_id, row) VALUES (?1, 'forced-conflict', X'7b7d')", + [second_document], + ) + .expect("install second-page conflict"); + drop(connection); + + assert!(matches!( + builder.append_pages(&pages[..2], &control), + Err(CodeLexicalArtifactErrorV1::Contract(_)) + )); + assert_eq!( + builder + .progress() + .expect("progress after rollback") + .next_page_ordinal, + 0, + "a failed later page must roll back every earlier page in the batch" + ); + let connection = rusqlite::Connection::open(&artifact_path).expect("inspect rolled back batch"); + let receipts: i64 = connection + .query_row("SELECT COUNT(*) FROM source_pages", [], |row| row.get(0)) + .expect("source receipt count"); + assert_eq!(receipts, 0, "no page receipt may escape the failed batch"); + connection + .execute("DELETE FROM rows WHERE document_id = ?1", [second_document]) + .expect("remove forced conflict"); + drop(connection); + + let progress = builder + .append_pages(&pages[..2], &control) + .expect("retry exact ordered batch"); + assert_eq!(progress.next_page_ordinal, 2); + assert_eq!( + progress.completed_chunks, + pages[0].chunk_count() + pages[1].chunk_count() + ); +} + +#[test] +fn disk_artifact_receipt_failure_rolls_back_prior_page_rows_and_receipts() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + assert!(pages.len() >= 2, "fixture must emit a multi-page batch"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("receipt-failure-batch.sqlite"); + let control = ArtifactControl { cancelled: false }; + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) + .expect("create artifact"); + let connection = rusqlite::Connection::open(&artifact_path).expect("install receipt failpoint"); + connection + .execute_batch( + "CREATE TRIGGER fail_second_page_receipt + BEFORE INSERT ON source_pages + WHEN NEW.page_ordinal = 1 + BEGIN SELECT RAISE(ABORT, 'forced receipt failure'); END;", + ) + .expect("create receipt failpoint"); + drop(connection); + + assert!(builder.append_pages(&pages[..2], &control).is_err()); + assert_eq!( + builder + .progress() + .expect("progress after receipt failure") + .next_page_ordinal, + 0 + ); + assert_eq!( + staged_row_cardinality(&artifact_path).0, + 0, + "receipt failure must roll back all prior relational writes" + ); +} + +#[test] +fn disk_artifact_committed_batch_replays_after_restart_without_duplicate_rows() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + assert!(pages.len() >= 2, "fixture must emit a multi-page batch"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("commit-ack-gap.sqlite"); + let metadata = fixture.metadata; + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, metadata.clone()) + .expect("create artifact"); + let control = ArtifactControl { cancelled: false }; + builder + .append_pages(&pages[..2], &control) + .expect("commit exact ordered batch"); + assert_eq!( + builder + .progress() + .expect("durable progress after commit") + .next_page_ordinal, + 2, + "the whole batch must be durable before source acknowledgement" + ); + drop(builder); + let mut builder = CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &artifact_path, + metadata, + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("resume after a process boundary"); + let replay = builder + .append_pages(&pages[..2], &control) + .expect("replay batch after source cursor was not acknowledged"); + assert_eq!(replay.next_page_ordinal, 2); + assert_eq!( + staged_row_cardinality(&artifact_path).0, + pages[0].chunk_count() + pages[1].chunk_count(), + "restart replay must not duplicate relational rows" + ); +} + +#[test] +fn disk_artifact_batch_ledger_charges_every_parallel_preparation_upper_bound() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + assert!(pages.len() >= 2, "fixture must emit a multi-page batch"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let builder = CodeLexicalArtifactBuilderV1::create( + directory.path().join("batch-ledger.sqlite"), + fixture.metadata, + ) + .expect("create artifact"); + let control = ArtifactControl { cancelled: false }; + let pages = pages.as_slice(); + assert!( + pages.iter().any(|page| !page.imports().is_empty()), + "ledger fixture must retain import evidence" + ); + assert!( + pages.iter().flat_map(|page| page.chunks()).any(|chunk| { + chunk.chunk().sanitized_text.as_str().len() >= 3 + && (!chunk.chunk().subtokens.is_empty() || !chunk.chunk().exact_terms.is_empty()) + }), + "ledger fixture must exercise term and n-gram preparation" + ); + let source_retained = pages + .iter() + .try_fold(0usize, |total, page| { + total.checked_add(page.retained_owned_bytes()) + }) + .expect("retained page sum"); + let conservative_charge = builder + .page_batch_ledger_charge_bytes(pages) + .expect("batch ledger charge"); + let prepared = builder + .prepare_pages(pages, &control) + .expect("prepare deterministic ledger probe"); + let prepared_retained = prepared + .iter() + .map(|page| page.retained_owned_bytes()) + .sum::(); + let effective_workers = + tracedecay_code_index::parallelism::indexing_workers().min(prepared.len()); + let mut scratch = prepared + .iter() + .map(|page| page.preparation_scratch_bytes()) + .collect::>(); + scratch.sort_unstable_by(|left, right| right.cmp(left)); + let active_scratch = scratch.into_iter().take(effective_workers).sum::(); + let exact_prepared_charge = source_retained + prepared_retained + active_scratch; + assert!( + conservative_charge >= exact_prepared_charge, + "pre-preparation admission undercounted live batch components: conservative={conservative_charge}, source={source_retained}, prepared={prepared_retained}, active_scratch={active_scratch}, workers={effective_workers}, exact={exact_prepared_charge}" + ); + for (source, prepared) in pages.iter().zip(&prepared) { + let conservative = builder + .page_ledger_charge_bytes(source) + .expect("one-page ledger charge"); + let exact = prepared + .ledger_charge_bytes() + .expect("prepared page charge"); + assert!( + conservative >= exact, + "page {} preflight undercounted exact components: conservative={conservative}, source={}, prepared={}, scratch={}, exact={exact}", + source.page_ordinal(), + prepared.source_retained_bytes(), + prepared.retained_owned_bytes(), + prepared.preparation_scratch_bytes(), + ); + } +} + +#[test] +fn disk_artifact_one_page_wrapper_matches_the_batch_path() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let control = ArtifactControl { cancelled: false }; + let mut wrapper = CodeLexicalArtifactBuilderV1::create( + directory.path().join("one-page-wrapper.sqlite"), + fixture.metadata.clone(), + ) + .expect("create wrapper artifact"); + let mut batch = CodeLexicalArtifactBuilderV1::create( + directory.path().join("one-page-batch.sqlite"), + fixture.metadata, + ) + .expect("create batch artifact"); + + assert_eq!( + wrapper + .append_page(&pages[0], &control) + .expect("append through wrapper"), + batch + .append_pages(&pages[..1], &control) + .expect("append through batch path") + ); +} + #[test] fn disk_artifact_budget_refusal_precedes_progress_and_accepts_boundary() { let (fixture, pages, source_receipt) = real_verified_pages_with_maximum_page_chunks(1); @@ -1859,7 +2209,7 @@ fn disk_artifact_budget_refusal_precedes_progress_and_accepts_boundary() { .len(); assert!(matches!( refused.append_page(&pages[0], &control), - Err(CodeLexicalArtifactErrorV1::Contract(_)) + Err(CodeLexicalArtifactErrorV1::BatchTooLarge { .. }) )); assert_eq!( refused From 6a6253b31e72a94e027b2941a0d4eaaa8d69e9be Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 00:42:51 +0000 Subject: [PATCH 07/69] fix(ci): validate hotpath repository setup --- .gitignore | 1 - .../src/retrieval/lexical/projection/artifact/reader.rs | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 3d6e7f2531..7bdbfcca08 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ .DS_Store .codegraph .tracedecay -.mcp.json !plugin/.mcp.json .claude/settings.local.json .cursor/ diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs index 81fb9c5c52..1565aa4875 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs @@ -2007,6 +2007,7 @@ mod tests { use std::cmp::Reverse; use std::collections::{BTreeSet, BinaryHeap}; use std::path::PathBuf; + #[cfg(feature = "hotpath")] use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -2014,9 +2015,11 @@ mod tests { use tracedecay_domain::ManifestDigest; use tracedecay_private_fs::open_private_file; + #[cfg(feature = "hotpath")] + use super::ArtifactConnectionMutex; use super::{ ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1, ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1, - ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, ArtifactConnectionMutex, ArtifactQueryMetricsV1, + ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, ArtifactQueryMetricsV1, CodeLexicalArtifactErrorV1, CodeLexicalArtifactReaderV1, DocumentQueryV1, NGRAM_NORMALIZED, map_query_artifact_error, ngram_document_query, query_ngrams, retain_bounded, union_document_queries, visit_document_ids, visit_lexical_rows, From 749fc7b5fad2100a32b471a59a6c627931cab89c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 01:07:10 +0000 Subject: [PATCH 08/69] feat(index): publish durable catch-up progress --- .../src/code_index_freshness_api.rs | 142 ++++ .../schemas/dashboard-contracts.schema.json | 199 ++++- dashboard/src/contracts/generated.ts | 41 + .../code/IndexFreshness.dom.test.tsx | 235 +++++- .../src/workspaces/code/IndexFreshness.tsx | 201 ++++- .../src/workspaces/endpoint-fixtures.test.ts | 52 +- .../observatory/ObservatoryPage.dom.test.tsx | 182 +++- .../observatory/ObservatoryPage.tsx | 233 +++++- dashboard/stories/fixtures/data.ts | 93 ++- src/daemon/code_index_scheduler.rs | 776 ++++++++++++++++-- src/daemon/code_index_scheduler/registry.rs | 27 +- src/daemon/code_index_scheduler/tests.rs | 456 +++++++++- 12 files changed, 2517 insertions(+), 120 deletions(-) diff --git a/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs b/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs index 07c7f84048..2870e9c66f 100644 --- a/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs +++ b/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs @@ -25,6 +25,81 @@ use super::read_model::{ DashboardFreshnessV1, DashboardLegalActionKindV1, DashboardLegalActionRefV1, scope_from_state, }; +/// The durable build phase whose committed boundary the dashboard is reading. +/// +/// A phase is not inferred from scheduler state. The mounted registry publishes +/// the exact phase that owns the active generation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CodeIndexBuildPhaseV1 { + SourceScan, + RelationalPreparation, + BulkCommit, + IndexBuild, + Verification, + Ready, +} + +/// A typed reason an otherwise active generation cannot make durable progress. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CodeIndexBuildBlockedReasonV1 { + ResidentMemory, + SourceUnavailable, + ArtifactStoreUnavailable, + RetryBackoff, +} + +/// The latest committed progress boundary for one active code-index generation. +/// +/// Every count is scoped to `generation_id`. The snapshot never includes a +/// staged page: work is reported only after the batch that owns it commits. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct CodeIndexBuildProgressV1 { + /// Exact generation receiving the committed build work. + pub generation_id: String, + /// Monotonic publication epoch for replacing delayed progress reads. + pub progress_epoch: u64, + /// Identity of the sealed source whose authenticated bounds define progress. + pub sealed_source_digest: String, + /// Durable pipeline phase that published this snapshot. + pub phase: CodeIndexBuildPhaseV1, + /// Source pages committed to the artifact database. + pub committed_pages: u64, + /// Search chunks committed to the artifact database. + pub committed_chunks: u64, + /// Import evidence rows committed to the artifact database. + pub committed_imports: u64, + /// Payload bytes committed to the artifact database. + pub committed_payload_bytes: u64, + /// Authenticated sealed-source file boundary completed by committed work. + pub completed_files: u64, + /// Authenticated sealed-source file bound for this generation. + pub total_files: u64, + /// Authenticated sealed lexical-byte boundary completed by committed work. + pub completed_lexical_bytes: u64, + /// Authenticated sealed lexical-byte bound for this generation. + pub total_lexical_bytes: u64, + /// Source pages in the batch currently being processed. + pub current_batch_pages: u64, + /// Sealed payload bytes in the batch currently being processed. + pub current_batch_payload_bytes: u64, + /// Monotonic elapsed time for this process's active generation build. + pub elapsed_micros: u64, + /// Duration of the last committed SQLite batch, when one exists. + pub last_commit_latency_micros: Option, + /// Rolling committed-file throughput, absent until it is established. + pub files_per_second: Option, + /// Rolling committed lexical-byte throughput, absent until it is established. + pub lexical_bytes_per_second: Option, + /// Estimated remaining build duration, absent without a truthful rate. + pub estimated_remaining_seconds: Option, + /// Unix-epoch timestamp of the last durable progress publication. + pub last_progress_micros: i64, + /// Reason the active generation cannot currently advance, when known. + pub blocked_reason: Option, +} + /// Freshness/generation state for one mounted worktree. /// /// `Deserialize` is part of the wire contract: the CLI status command decodes @@ -58,6 +133,8 @@ pub struct CodeIndexWorktreeFreshnessV1 { pub hook_hint_count: Option, /// Whether this read covers the complete mounted scheduler state. pub coverage: String, + /// Latest committed progress for the active generation, if one is mounted. + pub progress: Option, } pub type CodeIndexFreshnessReadFuture = @@ -201,6 +278,7 @@ mod tests { staleness_state: Some("fresh".to_owned()), hook_hint_count: Some(0), coverage: "complete".to_owned(), + progress: None, }) }) })); @@ -238,6 +316,7 @@ mod tests { staleness_state: Some("indexing".to_owned()), hook_hint_count: Some(0), coverage: "complete".to_owned(), + progress: None, }) }) })); @@ -248,6 +327,69 @@ mod tests { assert!(!envelope.coverage.is_complete()); } + #[tokio::test] + async fn freshness_route_preserves_committed_generation_progress_exactly() { + let _pin = tracedecay_runtime_core::config::PinnedUserDataDir::new(); + let (_project, mut state) = state_for_test().await; + state.code_index_freshness_reader = Some(Arc::new(|root| { + Box::pin(async move { + Some(CodeIndexWorktreeFreshnessV1 { + worktree_root: root.display().to_string(), + repository_id: Some("repository.fixture".to_owned()), + worktree_id: Some("worktree.fixture".to_owned()), + source_reference: Some("refs/heads/main".to_owned()), + source_revision: Some("commit.fixture".to_owned()), + latest_generation_id: None, + snapshot_content_identity: None, + sealed_at_micros: None, + last_reconcile_micros: Some(42), + staleness_state: Some("indexing".to_owned()), + hook_hint_count: Some(0), + coverage: "complete".to_owned(), + progress: Some(CodeIndexBuildProgressV1 { + generation_id: "generation.catchup.01".to_owned(), + progress_epoch: 7, + sealed_source_digest: "sha256:sealed-source-catchup".to_owned(), + phase: CodeIndexBuildPhaseV1::BulkCommit, + committed_pages: 16, + committed_chunks: 10_000, + committed_imports: 480, + committed_payload_bytes: 16 * 1024 * 1024, + completed_files: 250, + total_files: 500, + completed_lexical_bytes: 32 * 1024 * 1024, + total_lexical_bytes: 64 * 1024 * 1024, + current_batch_pages: 4, + current_batch_payload_bytes: 4 * 1024 * 1024, + elapsed_micros: 120_000_000, + last_commit_latency_micros: Some(240_000), + files_per_second: Some(250.0), + lexical_bytes_per_second: Some(16.0 * 1024.0 * 1024.0), + estimated_remaining_seconds: Some(120), + last_progress_micros: 43, + blocked_reason: None, + }), + }) + }) + })); + + let Json(envelope) = freshness(State(state)).await; + + let progress = envelope.payload.worktrees[0] + .progress + .as_ref() + .expect("mounted build progress"); + assert_eq!(progress.generation_id, "generation.catchup.01"); + assert_eq!(progress.progress_epoch, 7); + assert_eq!(progress.phase, CodeIndexBuildPhaseV1::BulkCommit); + assert_eq!(progress.completed_files, 250); + assert_eq!(progress.total_files, 500); + assert_eq!(progress.completed_lexical_bytes, 32 * 1024 * 1024); + assert_eq!(progress.total_lexical_bytes, 64 * 1024 * 1024); + assert_eq!(progress.files_per_second, Some(250.0)); + assert_eq!(progress.estimated_remaining_seconds, Some(120)); + } + #[tokio::test] async fn attached_registry_without_a_mount_is_unknown_not_unsupported() { let _pin = tracedecay_runtime_core::config::PinnedUserDataDir::new(); diff --git a/dashboard/codegen/schemas/dashboard-contracts.schema.json b/dashboard/codegen/schemas/dashboard-contracts.schema.json index b9396acc49..12b34eba46 100644 --- a/dashboard/codegen/schemas/dashboard-contracts.schema.json +++ b/dashboard/codegen/schemas/dashboard-contracts.schema.json @@ -2167,6 +2167,191 @@ "description": "Strongly typed canonical identity: `CatalogGenerationId`.", "type": "string" }, + "CodeIndexBuildBlockedReasonV1": { + "description": "A typed reason an otherwise active generation cannot make durable progress.", + "enum": [ + "resident_memory", + "source_unavailable", + "artifact_store_unavailable", + "retry_backoff" + ], + "type": "string" + }, + "CodeIndexBuildPhaseV1": { + "description": "The durable build phase whose committed boundary the dashboard is reading.\n\nA phase is not inferred from scheduler state. The mounted registry publishes\nthe exact phase that owns the active generation.", + "enum": [ + "source_scan", + "relational_preparation", + "bulk_commit", + "index_build", + "verification", + "ready" + ], + "type": "string" + }, + "CodeIndexBuildProgressV1": { + "description": "The latest committed progress boundary for one active code-index generation.\n\nEvery count is scoped to `generation_id`. The snapshot never includes a\nstaged page: work is reported only after the batch that owns it commits.", + "properties": { + "blocked_reason": { + "anyOf": [ + { + "$ref": "#/$defs/CodeIndexBuildBlockedReasonV1" + }, + { + "type": "null" + } + ], + "description": "Reason the active generation cannot currently advance, when known." + }, + "committed_chunks": { + "description": "Search chunks committed to the artifact database.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "committed_imports": { + "description": "Import evidence rows committed to the artifact database.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "committed_pages": { + "description": "Source pages committed to the artifact database.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "committed_payload_bytes": { + "description": "Payload bytes committed to the artifact database.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "completed_files": { + "description": "Authenticated sealed-source file boundary completed by committed work.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "completed_lexical_bytes": { + "description": "Authenticated sealed lexical-byte boundary completed by committed work.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "current_batch_pages": { + "description": "Source pages in the batch currently being processed.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "current_batch_payload_bytes": { + "description": "Sealed payload bytes in the batch currently being processed.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "elapsed_micros": { + "description": "Monotonic elapsed time for this process's active generation build.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "estimated_remaining_seconds": { + "description": "Estimated remaining build duration, absent without a truthful rate.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "files_per_second": { + "description": "Rolling committed-file throughput, absent until it is established.", + "format": "double", + "type": [ + "number", + "null" + ] + }, + "generation_id": { + "description": "Exact generation receiving the committed build work.", + "type": "string" + }, + "last_commit_latency_micros": { + "description": "Duration of the last committed SQLite batch, when one exists.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "last_progress_micros": { + "description": "Unix-epoch timestamp of the last durable progress publication.", + "format": "int64", + "type": "integer" + }, + "lexical_bytes_per_second": { + "description": "Rolling committed lexical-byte throughput, absent until it is established.", + "format": "double", + "type": [ + "number", + "null" + ] + }, + "phase": { + "$ref": "#/$defs/CodeIndexBuildPhaseV1", + "description": "Durable pipeline phase that published this snapshot." + }, + "progress_epoch": { + "description": "Monotonic publication epoch for replacing delayed progress reads.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "sealed_source_digest": { + "description": "Identity of the sealed source whose authenticated bounds define progress.", + "type": "string" + }, + "total_files": { + "description": "Authenticated sealed-source file bound for this generation.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "total_lexical_bytes": { + "description": "Authenticated sealed lexical-byte bound for this generation.", + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "generation_id", + "progress_epoch", + "sealed_source_digest", + "phase", + "committed_pages", + "committed_chunks", + "committed_imports", + "committed_payload_bytes", + "completed_files", + "total_files", + "completed_lexical_bytes", + "total_lexical_bytes", + "current_batch_pages", + "current_batch_payload_bytes", + "elapsed_micros", + "last_commit_latency_micros", + "files_per_second", + "lexical_bytes_per_second", + "estimated_remaining_seconds", + "last_progress_micros", + "blocked_reason" + ], + "type": "object" + }, "CodeIndexFreshnessPayloadV1": { "properties": { "note": { @@ -2332,6 +2517,17 @@ "null" ] }, + "progress": { + "anyOf": [ + { + "$ref": "#/$defs/CodeIndexBuildProgressV1" + }, + { + "type": "null" + } + ], + "description": "Latest committed progress for the active generation, if one is mounted." + }, "repository_id": { "description": "Stable repository identity resolved by the scheduler.", "type": [ @@ -2399,7 +2595,8 @@ "last_reconcile_micros", "staleness_state", "hook_hint_count", - "coverage" + "coverage", + "progress" ], "type": "object" }, diff --git a/dashboard/src/contracts/generated.ts b/dashboard/src/contracts/generated.ts index f70ed015fa..3e17abcf35 100644 --- a/dashboard/src/contracts/generated.ts +++ b/dashboard/src/contracts/generated.ts @@ -636,6 +636,46 @@ export type CapabilityId = z.infer; export const CatalogGenerationIdSchema = z.string(); export type CatalogGenerationId = z.infer; +/** A typed reason an otherwise active generation cannot make durable progress. */ +export const CodeIndexBuildBlockedReasonV1Schema = z.enum(["artifact_store_unavailable", "resident_memory", "retry_backoff", "source_unavailable"]); +export type CodeIndexBuildBlockedReasonV1 = z.infer; + +/** The durable build phase whose committed boundary the dashboard is reading. + +A phase is not inferred from scheduler state. The mounted registry publishes +the exact phase that owns the active generation. */ +export const CodeIndexBuildPhaseV1Schema = z.enum(["bulk_commit", "index_build", "ready", "relational_preparation", "source_scan", "verification"]); +export type CodeIndexBuildPhaseV1 = z.infer; + +/** The latest committed progress boundary for one active code-index generation. + +Every count is scoped to `generation_id`. The snapshot never includes a +staged page: work is reported only after the batch that owns it commits. */ +export const CodeIndexBuildProgressV1Schema = z.object({ + blocked_reason: z.union([z.lazy(() => CodeIndexBuildBlockedReasonV1Schema), z.null()]), + committed_chunks: z.number().int().safe().min(0), + committed_imports: z.number().int().safe().min(0), + committed_pages: z.number().int().safe().min(0), + committed_payload_bytes: z.number().int().safe().min(0), + completed_files: z.number().int().safe().min(0), + completed_lexical_bytes: z.number().int().safe().min(0), + current_batch_pages: z.number().int().safe().min(0), + current_batch_payload_bytes: z.number().int().safe().min(0), + elapsed_micros: z.number().int().safe().min(0), + estimated_remaining_seconds: z.number().int().safe().min(0).nullable(), + files_per_second: z.number().nullable(), + generation_id: z.string(), + last_commit_latency_micros: z.number().int().safe().min(0).nullable(), + last_progress_micros: z.number().int().safe(), + lexical_bytes_per_second: z.number().nullable(), + phase: z.lazy(() => CodeIndexBuildPhaseV1Schema), + progress_epoch: z.number().int().safe().min(0), + sealed_source_digest: z.string(), + total_files: z.number().int().safe().min(0), + total_lexical_bytes: z.number().int().safe().min(0), +}); +export type CodeIndexBuildProgressV1 = z.infer; + export const CodeIndexFreshnessPayloadV1Schema = z.object({ note: z.string(), worktrees: z.array(z.lazy(() => CodeIndexWorktreeFreshnessV1Schema)), @@ -685,6 +725,7 @@ export const CodeIndexWorktreeFreshnessV1Schema = z.object({ hook_hint_count: z.number().int().safe().min(0).nullable(), last_reconcile_micros: z.number().int().safe().nullable(), latest_generation_id: z.string().nullable(), + progress: z.union([z.lazy(() => CodeIndexBuildProgressV1Schema), z.null()]), repository_id: z.string().nullable(), sealed_at_micros: z.number().int().safe().nullable(), snapshot_content_identity: z.string().nullable(), diff --git a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx index b5c9154cda..18923b0e3a 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { IndexFreshness } from './IndexFreshness.tsx'; @@ -15,6 +15,7 @@ const NOW_MICROS = 1_753_003_600_000_000; afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); }); describe('Code index freshness', () => { @@ -80,6 +81,205 @@ describe('Code index freshness', () => { expect(screen.getAllByText('not reported').length).toBeGreaterThan(0); }); + it('renders exact committed build progress from the mounted generation', async () => { + renderFreshness('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: progress(), + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + + const reading = await screen.findByRole('progressbar', { name: 'Code progress' }); + expect(reading.getAttribute('value')).toBe('50'); + expect(reading.getAttribute('max')).toBe('100'); + const panel = reading.closest('[data-code-index-progress]'); + const text = panel?.textContent ?? ''; + expect(text).toContain('bulk commit · 50.0%'); + expect(text).toContain('generation.catchup.01'); + expect(text).toContain('250 / 500 files'); + expect(text).toContain('16 pages committed'); + expect(text).toContain('10k chunks committed'); + expect(text).toContain('480 imports committed'); + expect(text).toContain('16.0 MiB payload committed'); + expect(text).toContain('250 files/s · 16.0 MiB lexical bytes/s'); + expect(text).toContain('ETA 2m'); + expect(text).toContain('last commit'); + expect(text).toContain('240ms'); + }); + + it('does not render rate-dependent ETA without an established backend rate', async () => { + renderFreshness('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { + ...progress(), + files_per_second: null, + lexical_bytes_per_second: null, + estimated_remaining_seconds: 120, + blocked_reason: 'retry_backoff', + }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + + const panel = (await screen.findByRole('progressbar', { name: 'Code progress' })).closest( + '[data-code-index-progress]', + ); + const text = panel?.textContent ?? ''; + expect(text).toContain('throughput unavailable'); + expect(text).toContain('ETA unavailable'); + expect(text).toContain('blocked: retry backoff'); + expect(text).not.toContain('0 files/s'); + expect(text).not.toContain('0 B/s'); + }); + + it('removes progress when the generation is ready and has no active build', async () => { + renderFreshness('ready', { + worktrees: [{ ...worktree(), progress: null }], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + + await screen.findByText('Ready'); + expect(screen.queryByRole('progressbar', { name: 'Code progress' })).toBeNull(); + expect(screen.queryByText(/throughput unavailable/)).toBeNull(); + }); + + it('accepts a later replacement generation after epoch restart and rejects its delayed predecessor', async () => { + vi.useFakeTimers(); + const first = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: progress(), + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const replacement = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { + ...progress(), + generation_id: 'generation.catchup.02', + // The daemon restarted before this generation began, so its + // in-memory progress epoch is lower than the rendered generation. + progress_epoch: 0, + last_progress_micros: NOW_MICROS + 1, + completed_files: 1, + }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(first), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(replacement), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(first), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('generation.catchup.01')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('generation.catchup.02')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('generation.catchup.01')).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('polls an active build each second and returns to the ready cadence', async () => { + vi.useFakeTimers(); + const active = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: progress(), + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const ready = envelope('ready', { + worktrees: [ + { + ...worktree(), + latest_generation_id: 'generation.catchup.01', + progress: null, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(active), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('generation.catchup.01')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('Ready')).toBeTruthy(); + expect(fetch).toHaveBeenCalledTimes(2); + await advanceTimers(29_998); + expect(fetch).toHaveBeenCalledTimes(2); + await advanceTimers(1); + await advanceTimers(0); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('keeps a mounted ready worktree without progress on the 30-second cadence', async () => { + vi.useFakeTimers(); + const ready = envelope('ready', { + worktrees: [{ ...worktree(), progress: null }], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('Ready')).toBeTruthy(); + expect(fetch).toHaveBeenCalledTimes(1); + await advanceTimers(29_999); + expect(fetch).toHaveBeenCalledTimes(1); + await advanceTimers(1); + expect(fetch).toHaveBeenCalledTimes(2); + }); + it('reports a stale generation as stale against the reference it was sealed on', async () => { renderFreshness('partial', { worktrees: [{ ...worktree(), staleness_state: 'stale', coverage: 'partial' }], @@ -120,6 +320,33 @@ function worktree() { staleness_state: 'fresh', hook_hint_count: 0, coverage: 'complete', + progress: null, + }; +} + +function progress() { + return { + generation_id: 'generation.catchup.01', + progress_epoch: 1, + sealed_source_digest: 'sha256:sealed-source-catchup', + phase: 'bulk_commit', + committed_pages: 16, + committed_chunks: 10_000, + committed_imports: 480, + committed_payload_bytes: 16 * 1024 * 1024, + completed_files: 250, + total_files: 500, + completed_lexical_bytes: 32 * 1024 * 1024, + total_lexical_bytes: 64 * 1024 * 1024, + current_batch_pages: 4, + current_batch_payload_bytes: 4 * 1024 * 1024, + elapsed_micros: 120_000_000, + last_commit_latency_micros: 240_000, + files_per_second: 250, + lexical_bytes_per_second: 16 * 1024 * 1024, + estimated_remaining_seconds: 120, + last_progress_micros: NOW_MICROS, + blocked_reason: null, }; } @@ -143,6 +370,12 @@ function renderWith() { ); } +async function advanceTimers(milliseconds: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds); + }); +} + function envelope(domainState: string, payload: unknown) { return { schema_revision: 1, diff --git a/dashboard/src/workspaces/code/IndexFreshness.tsx b/dashboard/src/workspaces/code/IndexFreshness.tsx index c63518c9bc..913230bd65 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.tsx @@ -25,6 +25,7 @@ * the payload fields. */ import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { CodeIndexFreshnessPayloadV1Schema, type CodeIndexFreshnessPayloadV1, @@ -34,7 +35,9 @@ import { fetchEnvelope, type EnvelopeResult } from '../../data/query/envelope.ts import { scopeKey, scopedUrl, useScope } from '../../data/scope/store.ts'; import { authorizationState } from '../../ui/EnvelopeTruth.tsx'; import { StateChip } from '../../ui/StateChip.tsx'; -import { elideStart, formatMicrosUtc } from '../../ui/format.ts'; +import { elideStart, formatCount, formatMicrosUtc, splitBytes } from '../../ui/format.ts'; + +type CodeIndexBuildProgress = NonNullable; export function IndexFreshness() { const scope = useScope((s) => s.scope); @@ -42,7 +45,7 @@ export function IndexFreshness() { queryKey: ['code-index', 'freshness', scopeKey(scope)], queryFn: () => fetchEnvelope(scopedUrl(scope, '/api/code-index/freshness'), CodeIndexFreshnessPayloadV1Schema), - refetchInterval: 30_000, + refetchInterval: (query) => (hasActiveBuild(query.state.data) ? 1_000 : 30_000), }); return ( @@ -63,6 +66,7 @@ export function IndexFreshness() { } function FreshnessReading({ result }: { result: EnvelopeResult }) { + const latestProgress = useLatestBuildProgress(result); if (result.outcome === 'transport') { return (
@@ -83,7 +87,11 @@ function FreshnessReading({ result }: { result: EnvelopeResult ) : null} {worktrees.map((worktree) => ( - + ))} {/* The route's own sentence for why the list is the length it is. It is * the only thing distinguishing "no scheduler is attached" from "a @@ -101,7 +109,13 @@ function FreshnessReading({ result }: { result: EnvelopeResult
+ {progress ? : null}
{worktree.staleness_state ?? 'not reported'} {worktree.coverage} @@ -152,6 +167,184 @@ function WorktreeReading({ worktree }: { worktree: CodeIndexWorktreeFreshnessV1 ); } +function BuildProgressReading({ + progress, +}: { + progress: CodeIndexBuildProgress; +}) { + const percentage = progressPercentage(progress); + const hasRate = + progress.files_per_second != null && progress.lexical_bytes_per_second != null; + return ( +
+
+ Code progress + + {phaseLabel(progress.phase)} · {percentage.toFixed(1)}% + +
+ +
+ + {progress.generation_id} + + + {`${formatCount(progress.completed_files)} / ${formatCount(progress.total_files)} files`} + + {`${formatCount(progress.committed_pages)} pages committed`} + {`${formatCount(progress.committed_chunks)} chunks committed`} + {`${formatCount(progress.committed_imports)} imports committed`} + {`${formatBytes(progress.committed_payload_bytes)} payload committed`} + + {`${formatCount(progress.current_batch_pages)} pages · ${formatBytes(progress.current_batch_payload_bytes)}`} + + + {progress.files_per_second != null && progress.lexical_bytes_per_second != null + ? `${formatCount(progress.files_per_second)} files/s · ${formatBytes(progress.lexical_bytes_per_second)} lexical bytes/s` + : 'throughput unavailable'} + + + {hasRate && progress.estimated_remaining_seconds != null + ? `ETA ${formatDurationSeconds(progress.estimated_remaining_seconds)}` + : 'ETA unavailable'} + + {formatMicros(progress.last_progress_micros)} + + {progress.last_commit_latency_micros != null + ? formatDurationMicros(progress.last_commit_latency_micros) + : 'not reported'} + +
+ {progress.blocked_reason ? ( +

blocked: {blockedReasonLabel(progress.blocked_reason)}

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

reading code-index pipeline…

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

no active code-index build

+
+ ); + } + return ( +
+

Code-index pipeline

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

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

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

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

+ ) : null} +
+ ); +} + +function hasActiveCodeIndexBuild( + result: EnvelopeResult | undefined, +): boolean { + return ( + result?.outcome === 'envelope' && + result.envelope.payload.worktrees.some( + (worktree) => worktree.progress != null && worktree.progress.phase !== 'ready', + ) + ); +} + +function useLatestCodeIndexProgress( + result: EnvelopeResult | undefined, +): readonly CodeIndexBuildProgressV1[] { + const [latestProgress, setLatestProgress] = useState>( + () => new Map(), + ); + useEffect(() => { + if (result?.outcome !== 'envelope') return; + setLatestProgress((rendered) => { + const next = new Map(rendered); + let changed = false; + for (const worktree of result.envelope.payload.worktrees) { + const incoming = worktree.progress; + const current = next.get(worktree.worktree_root); + if (!incoming) { + if ( + current && + result.envelope.domain_state === 'ready' && + worktree.latest_generation_id === current.generation_id + ) { + next.delete(worktree.worktree_root); + changed = true; + } + } else if (!current || isCurrentOrNewerCodeIndexProgress(incoming, current)) { + next.set(worktree.worktree_root, incoming); + changed = true; + } + } + return changed ? next : rendered; + }); + }, [result]); + return [...latestProgress.values()]; +} + +function isCurrentOrNewerCodeIndexProgress( + incoming: CodeIndexBuildProgressV1, + rendered: CodeIndexBuildProgressV1, +): boolean { + if (incoming.generation_id === rendered.generation_id) { + return incoming.progress_epoch >= rendered.progress_epoch; + } + // A daemon restart resets its in-memory epoch, so replacement generations + // order by their immutable publication time. An older delayed response is + // not evidence of a later generation and stays rejected. + return incoming.last_progress_micros > rendered.last_progress_micros; +} + +function codeIndexProgressPercentage(progress: CodeIndexBuildProgressV1): number { + const completed = + progress.total_lexical_bytes > 0 + ? progress.completed_lexical_bytes / progress.total_lexical_bytes + : progress.phase === 'ready' + ? 1 + : 0; + return Math.min(100, Math.max(0, completed * 100)); +} + +function codeIndexPhaseLabel(phase: CodeIndexBuildProgressV1['phase']): string { + switch (phase) { + case 'source_scan': + return 'source scan'; + case 'relational_preparation': + return 'relational preparation'; + case 'bulk_commit': + return 'bulk commit'; + case 'index_build': + return 'index build'; + case 'verification': + return 'verification'; + case 'ready': + return 'ready'; + } +} + +function codeIndexBlockedReasonLabel(reason: CodeIndexBuildProgressV1['blocked_reason']): string { + switch (reason) { + case 'resident_memory': + return 'resident memory'; + case 'source_unavailable': + return 'source unavailable'; + case 'artifact_store_unavailable': + return 'artifact store unavailable'; + case 'retry_backoff': + return 'retry backoff'; + case null: + return 'not blocked'; + } +} + +function formatDurationMicros(micros: number): string { + if (micros < 1_000) return `${micros}µs`; + if (micros < 1_000_000) return `${Math.round(micros / 1_000)}ms`; + const seconds = micros / 1_000_000; + if (seconds < 90) return `${Math.round(seconds)}s`; + if (seconds < 5_400) return `${Math.round(seconds / 60)}m`; + const hours = Math.floor(seconds / 3_600); + const minutes = Math.round((seconds % 3_600) / 60); + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +} + function TelemetryReadModel({ envelope, refreshing, diff --git a/dashboard/stories/fixtures/data.ts b/dashboard/stories/fixtures/data.ts index 59e264c5c7..7d431b4911 100644 --- a/dashboard/stories/fixtures/data.ts +++ b/dashboard/stories/fixtures/data.ts @@ -2635,6 +2635,38 @@ const ANALYTICS_DESCRIPTOR = 'analytics-observability.v1'; const FEEDBACK_DESCRIPTOR = 'feedback-system-quality.v1'; const COST_DESCRIPTOR = 'accounting-cost.v1'; +/** + * The same freshness endpoint at four truthful mounted-registry moments. The + * static visual-audit route stays ready/absent; the remaining snapshots give + * DOM and endpoint-contract tests a complete active, rate-unavailable, and + * superseded-generation read without inventing query parameters the API does + * not accept. + */ +export const CODE_INDEX_FRESHNESS_FIXTURES = { + active: codeIndexFreshnessEnvelope(codeIndexBuildProgressFixture()), + unavailable_rate: codeIndexFreshnessEnvelope( + codeIndexBuildProgressFixture({ + files_per_second: null, + lexical_bytes_per_second: null, + estimated_remaining_seconds: null, + blocked_reason: 'retry_backoff', + }), + ), + ready_absent: codeIndexFreshnessEnvelope(null), + generation_replacement: [ + codeIndexFreshnessEnvelope(codeIndexBuildProgressFixture()), + codeIndexFreshnessEnvelope( + codeIndexBuildProgressFixture({ + generation_id: 'generation.catchup.02', + progress_epoch: 2, + completed_files: 1, + }), + ), + // A delayed pre-supersession read. Consumers must retain the newer epoch. + codeIndexFreshnessEnvelope(codeIndexBuildProgressFixture()), + ], +} as const; + /** * Exact-path fixture map. Keys are the pathname (query string stripped by the * resolver). Anything not listed resolves to the prefix table, then to {}. @@ -2698,7 +2730,7 @@ export const FIXTURES: Readonly> = { // Code-index freshness. Served against a mounted daemon scheduler, which is // the state the audit needs to shoot — the unattached case is a state chip // with no reading behind it. - '/api/code-index/freshness': codeIndexFreshnessEnvelope(), + '/api/code-index/freshness': CODE_INDEX_FRESHNESS_FIXTURES.ready_absent, '/api/remote/status': remoteOperationalStatusEnvelope(), // Work. The two mounted read routes. Unlike every other fixture here these // are wrapped in the application's `HttpJsonEnvelope` rather than @@ -3466,7 +3498,8 @@ function remoteOperationalStatusEnvelope(): Record { } /** GET /api/code-index/freshness (src/dashboard/code_index_freshness_api.rs). */ -function codeIndexFreshnessEnvelope(): Record { +function codeIndexFreshnessEnvelope(progress: Record | null): Record { + const active = progress !== null; const payload = { worktrees: [ { @@ -3475,36 +3508,66 @@ function codeIndexFreshnessEnvelope(): Record { worktree_id: 'worktree.primary', source_reference: 'refs/heads/codex/tracedecay-total-redesign-plan', source_revision: null, - latest_generation_id: 'generation.2f8c41ab', - snapshot_content_identity: 'sha256:9c1f4a2e7b05', - sealed_at_micros: nowMicros - 214_000_000, + latest_generation_id: active ? null : 'generation.2f8c41ab', + snapshot_content_identity: active ? null : 'sha256:9c1f4a2e7b05', + sealed_at_micros: active ? null : nowMicros - 214_000_000, last_reconcile_micros: nowMicros - 8_400_000, - staleness_state: 'fresh', + staleness_state: active ? 'indexing' : 'fresh', hook_hint_count: 0, coverage: 'complete', + progress, }, ], note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', }; return { - ...envelope(payload, 'ready', [ + ...envelope(payload, active ? 'loading' : 'ready', [ { kind: 'refresh', operation: 'use-case.dashboard.code-index.freshness.refresh' }, ]), coverage: { - completeness: 'complete', - eligible: 1, - examined: 1, - matched: 1, - excluded: 0, - omitted: 0, - unknown: 0, - denominator: 1, + completeness: active ? 'unknown' : 'complete', + eligible: active ? null : 1, + examined: active ? null : 1, + matched: active ? null : 1, + excluded: active ? null : 0, + omitted: active ? null : 0, + unknown: active ? null : 0, + denominator: active ? null : 1, unit: 'mounted_worktree', omission_reasons: [], }, }; } +function codeIndexBuildProgressFixture( + overrides: Partial> = {}, +): Record { + return { + generation_id: 'generation.catchup.01', + progress_epoch: 1, + sealed_source_digest: 'sha256:sealed-source-catchup', + phase: 'bulk_commit', + committed_pages: 16, + committed_chunks: 10_000, + committed_imports: 480, + committed_payload_bytes: 16 * 1024 * 1024, + completed_files: 250, + total_files: 500, + completed_lexical_bytes: 32 * 1024 * 1024, + total_lexical_bytes: 64 * 1024 * 1024, + current_batch_pages: 4, + current_batch_payload_bytes: 4 * 1024 * 1024, + elapsed_micros: 120_000_000, + last_commit_latency_micros: 240_000, + files_per_second: 250, + lexical_bytes_per_second: 16 * 1024 * 1024, + estimated_remaining_seconds: 120, + last_progress_micros: nowMicros - 1_000_000, + blocked_reason: null, + ...overrides, + }; +} + /** GET /api/plugins/holographic/status (src/dashboard/memory_api.rs `status`). */ function memoryStatusPayload(): Record { return { diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index e6015538db..a5b0670384 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -46,6 +46,9 @@ use tracedecay_usecases::code_index::{ }; use self::freshness_witness::RestoreFreshnessWitnessV1; +use crate::dashboard::code_index_freshness_api::{ + CodeIndexBuildBlockedReasonV1, CodeIndexBuildPhaseV1, CodeIndexBuildProgressV1, +}; use crate::{ code_index::{ @@ -60,8 +63,9 @@ use crate::{ CodeIndexIgnoredSourceAdmissionV1, CodeIndexInputErrorV1, CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, - SharedPhysicalCodeArtifactPoolV1, VerifiedSealedLexicalPageReadV1, - VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalSourceReceiptV1, + SharedPhysicalCodeArtifactPoolV1, VerifiedSealedLexicalPageBatchBoundsV1, + VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageSourceV1, + VerifiedSealedLexicalSourceReceiptV1, }, projection::{ ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, @@ -78,7 +82,9 @@ use crate::{ lexical::{ CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeExactLexicalArtifactReaderV1, - CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, CodeLexicalArtifactReaderV1, + CodeLexicalArtifactBatchLimitV1, CodeLexicalArtifactBuilderV1, + CodeLexicalArtifactErrorV1, CodeLexicalArtifactFinalizationPhaseV1, + CodeLexicalArtifactFinalizationStepV1, CodeLexicalArtifactReaderV1, CodeLexicalProjectionMetadataV1, LexicalLane, LexicalLaneEvidence, LexicalLaneRequest, LexicalLaneRetriever, }, @@ -110,6 +116,8 @@ const DURABLE_GENERATION_IO_CHUNK_BYTES_V1: usize = 64 * 1024; /// text artifact. One page is one bounded unit of background build progress. const TEXT_ARTIFACT_PAGE_CHUNKS_V1: usize = 128; const TEXT_ARTIFACT_PAGE_BYTES_V1: usize = 4 * 1024 * 1024; +const TEXT_ARTIFACT_BATCH_PAGES_V1: usize = 16; +const TEXT_ARTIFACT_BATCH_BYTES_V1: usize = 32 * 1024 * 1024; /// One synchronous activation advances only this many page/finalization /// operations. Larger caller hints are clamped so work accounting cannot /// overflow and every expensive loop retains cancellation checkpoints. @@ -1533,9 +1541,156 @@ type GenerationServingCachesV1 = ( Arc>, Arc>>, Arc, + Arc>, + u64, Arc>, ); +pub(super) type CodeIndexBuildProgressSlotV1 = Arc>; + +#[derive(Default)] +pub(super) struct CodeIndexBuildProgressSlotStateV1 { + generation_id: Option, + owner_epoch: u64, + progress_epoch: u64, + snapshot: Option>, +} + +impl CodeIndexBuildProgressSlotStateV1 { + fn replace_generation(&mut self, generation_id: CodeGenerationId) -> u64 { + self.owner_epoch = self.owner_epoch.saturating_add(1).max(1); + self.progress_epoch = self.progress_epoch.saturating_add(1).max(1); + self.generation_id = Some(generation_id); + self.snapshot = None; + self.owner_epoch + } + + fn publish( + &mut self, + generation_id: &CodeGenerationId, + owner_epoch: u64, + mut snapshot: CodeIndexBuildProgressV1, + ) -> bool { + if self.generation_id.as_ref() != Some(generation_id) || self.owner_epoch != owner_epoch { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.progress.rejected_stale_total").inc(1u64); + return false; + } + self.progress_epoch = self.progress_epoch.saturating_add(1).max(1); + snapshot.progress_epoch = self.progress_epoch; + #[cfg(feature = "hotpath")] + let published_phase = snapshot.phase; + self.snapshot = Some(Arc::new(snapshot)); + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.progress.publication_total").inc(1u64); + match published_phase { + CodeIndexBuildPhaseV1::SourceScan => { + hotpath::gauge!("query.artifact.progress.phase.source_scan_total").inc(1u64); + } + CodeIndexBuildPhaseV1::RelationalPreparation => { + hotpath::gauge!("query.artifact.progress.phase.preparation_total").inc(1u64); + } + CodeIndexBuildPhaseV1::BulkCommit => { + hotpath::gauge!("query.artifact.progress.phase.bulk_commit_total").inc(1u64); + } + CodeIndexBuildPhaseV1::IndexBuild => { + hotpath::gauge!("query.artifact.progress.phase.index_build_total").inc(1u64); + } + CodeIndexBuildPhaseV1::Verification => { + hotpath::gauge!("query.artifact.progress.phase.verification_total").inc(1u64); + } + CodeIndexBuildPhaseV1::Ready => { + hotpath::gauge!("query.artifact.progress.phase.ready_total").inc(1u64); + } + } + } + true + } + + pub(super) fn snapshot(&self) -> Option> { + self.snapshot.as_ref().map(Arc::clone) + } +} + +#[derive(Clone, Copy)] +struct CodeIndexCommittedProgressSampleV1 { + observed_at: Instant, + completed_files: u64, + completed_lexical_bytes: u64, +} + +struct CodeIndexBuildProgressStateV1 { + started_at: Instant, + committed_samples: VecDeque, +} + +impl CodeIndexBuildProgressStateV1 { + fn new() -> Self { + Self { + started_at: Instant::now(), + committed_samples: VecDeque::with_capacity(2), + } + } + + fn observe_committed(&mut self, sample: CodeIndexCommittedProgressSampleV1) { + if self.committed_samples.back().is_some_and(|previous| { + previous.completed_files == sample.completed_files + && previous.completed_lexical_bytes == sample.completed_lexical_bytes + }) { + return; + } + if self.committed_samples.len() == 2 { + self.committed_samples.pop_front(); + } + self.committed_samples.push_back(sample); + } + + fn elapsed_micros(&self) -> u64 { + u64::try_from(self.started_at.elapsed().as_micros()).unwrap_or(u64::MAX) + } + + fn rates_and_eta(&self, total_lexical_bytes: u64) -> (Option, Option, Option) { + let Some(previous) = self.committed_samples.front() else { + return (None, None, None); + }; + let Some(current) = self.committed_samples.back() else { + return (None, None, None); + }; + if self.committed_samples.len() < 2 || current.observed_at <= previous.observed_at { + return (None, None, None); + } + let elapsed_seconds = current + .observed_at + .duration_since(previous.observed_at) + .as_secs_f64(); + if elapsed_seconds <= 0.0 { + return (None, None, None); + } + let files_per_second = current + .completed_files + .checked_sub(previous.completed_files) + .filter(|delta| *delta > 0) + .map(|delta| delta as f64 / elapsed_seconds); + let lexical_bytes_per_second = current + .completed_lexical_bytes + .checked_sub(previous.completed_lexical_bytes) + .filter(|delta| *delta > 0) + .map(|delta| delta as f64 / elapsed_seconds); + let estimated_remaining_seconds = lexical_bytes_per_second.and_then(|lexical_rate| { + let remaining = total_lexical_bytes.saturating_sub(current.completed_lexical_bytes); + let estimate = (remaining as f64 / lexical_rate).ceil(); + (estimate.is_finite() && estimate >= 0.0 && estimate <= u64::MAX as f64) + .then_some(estimate as u64) + }); + ( + files_per_second, + lexical_bytes_per_second, + estimated_remaining_seconds, + ) + } +} + #[derive(Clone)] pub(in crate::daemon) struct LatestCompleteCodeIndexV1 { generation: Arc, @@ -1547,6 +1702,9 @@ pub(in crate::daemon) struct LatestCompleteCodeIndexV1 { /// are installed. text_projection_build: Arc>>, text_projection_failed: Arc, + text_progress_state: Arc>, + text_progress_slot: CodeIndexBuildProgressSlotV1, + text_progress_owner_epoch: u64, /// The durable text-artifact store for this generation's store root. text_artifact_store: DaemonCodeTextArtifactStoreV1, /// Exact scheduler authorities for shutdown and superseding source epochs. @@ -1640,7 +1798,8 @@ fn map_text_artifact_error(error: CodeLexicalArtifactErrorV1) -> RetrievalPortEr CodeLexicalArtifactErrorV1::Incompatible(_) => RetrievalPortError::IncompatibleProjection, CodeLexicalArtifactErrorV1::Contract(detail) => RetrievalPortError::Contract(detail), CodeLexicalArtifactErrorV1::Corrupt(detail) => RetrievalPortError::Contract(detail), - CodeLexicalArtifactErrorV1::Unreserved(_) => RetrievalPortError::BudgetExceeded, + CodeLexicalArtifactErrorV1::Unreserved(_) + | CodeLexicalArtifactErrorV1::BatchTooLarge { .. } => RetrievalPortError::BudgetExceeded, CodeLexicalArtifactErrorV1::Io(detail) | CodeLexicalArtifactErrorV1::Missing(detail) => { RetrievalPortError::AuthorityUnavailable(detail) } @@ -2293,6 +2452,217 @@ impl LatestCompleteCodeIndexV1 { }) } + fn publish_text_progress_boundary( + &self, + build: &CodeTextArtifactBuildV1, + progress: &tracedecay_query::retrieval::lexical::CodeLexicalArtifactBuildProgressV1, + phase: CodeIndexBuildPhaseV1, + current_batch_pages: u64, + current_batch_payload_bytes: u64, + last_commit_latency_micros: Option, + observe_committed: bool, + ) -> Result<(), RetrievalPortError> { + let source_cursor = build.source.cursor(); + match progress.next_cursor.as_ref() { + Some(cursor) if cursor == source_cursor => {} + None if progress.next_page_ordinal == 0 + && progress.completed_chunks == 0 + && progress.completed_payload_bytes == 0 + && progress.completed_imports == 0 + && source_cursor.next_page_ordinal() == 0 => {} + _ => { + return Err(RetrievalPortError::Contract( + "text-artifact progress does not match the accepted sealed-source cursor" + .to_owned(), + )); + } + } + if progress.next_page_ordinal != source_cursor.next_page_ordinal() + || progress.completed_chunks != source_cursor.emitted_chunks() + || progress.completed_payload_bytes != source_cursor.emitted_payload_bytes() + || progress.completed_imports != source_cursor.emitted_imports() + { + return Err(RetrievalPortError::Contract( + "text-artifact progress counters do not match the sealed-source cursor".to_owned(), + )); + } + let completed_files = build.source.completed_files(); + let completed_lexical_bytes = build + .source + .completed_lexical_bytes() + .map_err(map_sealed_page_source_error)?; + let total_lexical_bytes = build.source.total_lexical_bytes(); + let observed_at = Instant::now(); + let observed_micros = now_micros().0; + let last_commit_latency_micros = last_commit_latency_micros.or_else(|| { + self.text_progress_slot + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .snapshot() + .filter(|snapshot| { + snapshot.generation_id == self.generation.manifest().generation_id.as_str() + }) + .and_then(|snapshot| snapshot.last_commit_latency_micros) + }); + let mut state = self + .text_progress_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if observe_committed && progress.next_page_ordinal > 0 { + state.observe_committed(CodeIndexCommittedProgressSampleV1 { + observed_at, + completed_files, + completed_lexical_bytes, + }); + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.progress.committed_pages") + .set(progress.next_page_ordinal); + hotpath::gauge!("query.artifact.progress.committed_lexical_bytes") + .set(completed_lexical_bytes); + } + } + let (files_per_second, lexical_bytes_per_second, estimated_remaining_seconds) = + state.rates_and_eta(total_lexical_bytes); + let snapshot = CodeIndexBuildProgressV1 { + generation_id: self.generation.manifest().generation_id.as_str().to_owned(), + progress_epoch: 0, + sealed_source_digest: build.sealed_identity.digest.as_str().to_owned(), + phase, + committed_pages: progress.next_page_ordinal, + committed_chunks: progress.completed_chunks, + committed_imports: progress.completed_imports, + committed_payload_bytes: progress.completed_payload_bytes, + completed_files, + total_files: build.source.total_files(), + completed_lexical_bytes, + total_lexical_bytes, + current_batch_pages, + current_batch_payload_bytes, + elapsed_micros: state.elapsed_micros(), + last_commit_latency_micros, + files_per_second, + lexical_bytes_per_second, + estimated_remaining_seconds, + last_progress_micros: observed_micros, + blocked_reason: None, + }; + drop(state); + self.publish_text_progress_snapshot(snapshot); + Ok(()) + } + + fn ready_text_progress_snapshot( + &self, + reader: &CodeLexicalArtifactReaderV1, + sealed_identity: &DurableSealedCodeGenerationIdentityV1, + source: &VerifiedSealedLexicalPageSourceV1, + ) -> Result { + let artifact = reader.verified_artifact(); + let generation_id = &self.generation.manifest().generation_id; + if artifact.generation() != generation_id { + return Err(RetrievalPortError::GenerationMismatch); + } + let elapsed_micros = self + .text_progress_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .elapsed_micros(); + Ok(CodeIndexBuildProgressV1 { + generation_id: generation_id.as_str().to_owned(), + progress_epoch: 0, + sealed_source_digest: sealed_identity.digest.as_str().to_owned(), + phase: CodeIndexBuildPhaseV1::Ready, + committed_pages: artifact.page_count(), + committed_chunks: artifact.total_chunks(), + committed_imports: artifact.total_imports(), + committed_payload_bytes: artifact.total_payload_bytes(), + completed_files: source.total_files(), + total_files: source.total_files(), + completed_lexical_bytes: source.total_lexical_bytes(), + total_lexical_bytes: source.total_lexical_bytes(), + current_batch_pages: 0, + current_batch_payload_bytes: 0, + elapsed_micros, + last_commit_latency_micros: None, + files_per_second: None, + lexical_bytes_per_second: None, + estimated_remaining_seconds: None, + last_progress_micros: now_micros().0, + blocked_reason: None, + }) + } + + fn publish_text_progress_snapshot(&self, snapshot: CodeIndexBuildProgressV1) { + let generation_id = &self.generation.manifest().generation_id; + hotpath::measure_block!("query.artifact.progress.publish", { + let _ = self + .text_progress_slot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .publish(generation_id, self.text_progress_owner_epoch, snapshot); + }); + } + + fn publish_text_progress_phase( + &self, + phase: CodeIndexBuildPhaseV1, + current_batch_pages: u64, + current_batch_payload_bytes: u64, + ) { + let generation_id = &self.generation.manifest().generation_id; + let elapsed_micros = self + .text_progress_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .elapsed_micros(); + hotpath::measure_block!("query.artifact.progress.publish", { + let mut slot = self + .text_progress_slot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(current) = slot.snapshot() else { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.progress.no_snapshot_total").inc(1u64); + return; + }; + let mut snapshot = current.as_ref().clone(); + snapshot.phase = phase; + snapshot.current_batch_pages = current_batch_pages; + snapshot.current_batch_payload_bytes = current_batch_payload_bytes; + snapshot.elapsed_micros = elapsed_micros; + snapshot.blocked_reason = None; + let _ = slot.publish(generation_id, self.text_progress_owner_epoch, snapshot); + }); + } + + fn publish_text_progress_blocked(&self, reason: CodeIndexBuildBlockedReasonV1) { + let generation_id = &self.generation.manifest().generation_id; + hotpath::measure_block!("query.artifact.progress.publish", { + let mut slot = self + .text_progress_slot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(current) = slot.snapshot() else { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.progress.no_snapshot_total").inc(1u64); + return; + }; + let mut snapshot = current.as_ref().clone(); + snapshot.blocked_reason = Some(reason); + let _ = slot.publish(generation_id, self.text_progress_owner_epoch, snapshot); + }); + } + + #[cfg(feature = "hotpath")] + fn text_progress_phase(&self) -> Option { + self.text_progress_slot + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .snapshot() + .map(|snapshot| snapshot.phase) + } + /// Advance at most `maximum_work` bounded page/finalization operations on /// this sealed generation's durable text artifact. The mutex is both the /// generation-owned partial-state authority and the single-flight gate for @@ -2317,6 +2687,10 @@ impl LatestCompleteCodeIndexV1 { if self.query_owners.get().is_some() { return Ok(true); } + let control = DaemonCodeIndexControlV1::new( + Arc::clone(&self.text_control_epoch), + Arc::clone(&self.text_control_shutdown), + ); let mut build = self .text_projection_build .lock() @@ -2324,22 +2698,20 @@ impl LatestCompleteCodeIndexV1 { if self.query_owners.get().is_some() { return Ok(true); } - self.advance_artifact_text_serving(&mut build, maximum_work) + self.advance_artifact_text_serving(&mut build, maximum_work, &control) } /// The durable-artifact journey: reopen a published head when one exists, /// otherwise stream the sealed generation through the staging builder one /// bounded page window at a time, finalize, publish, and reopen. + #[hotpath::measure(label = "query.artifact.batch.scheduler_wake")] fn advance_artifact_text_serving( &self, build: &mut Option, maximum_work: usize, + control: &dyn CodeIndexExecutionControlV1, ) -> Result { let store = &self.text_artifact_store; - let control = DaemonCodeIndexControlV1::new( - Arc::clone(&self.text_control_epoch), - Arc::clone(&self.text_control_shutdown), - ); if build.is_none() { let generation_id = self.generation.manifest().generation_id.clone(); if let Some(descriptor) = store.published_descriptor(&generation_id)? { @@ -2358,11 +2730,17 @@ impl LatestCompleteCodeIndexV1 { &descriptor.artifact_digest, descriptor.artifact_size_bytes, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, - &control, + control, ); match reader { Ok(reader) => { + let sealed_identity = store.sealed_identity(&generation_id)?; + let source = store.open_sealed_source(&sealed_identity, control)?; + let ready_progress = + self.ready_text_progress_snapshot(&reader, &sealed_identity, &source)?; + drop(source); self.install_artifact_owners(reader, reader_reservation)?; + self.publish_text_progress_snapshot(ready_progress); return Ok(true); } Err(CodeLexicalArtifactErrorV1::Missing(_)) => { @@ -2400,7 +2778,7 @@ impl LatestCompleteCodeIndexV1 { let artifacts_root = code_text_artifacts_root(store.store_root()); ensure_private_text_artifacts_root(&artifacts_root)?; let staging_path = artifacts_root.join(format!(".text-artifact-{sealed_hex}.staging")); - let mut source = store.open_sealed_source(&sealed_identity, &control)?; + let mut source = store.open_sealed_source(&sealed_identity, control)?; let builder_budget = text_artifact_builder_budget(source.staging_window_bytes())?; let metadata = self.text_projection_metadata()?; let builder = if staging_path.exists() { @@ -2408,11 +2786,11 @@ impl LatestCompleteCodeIndexV1 { &staging_path, metadata.clone(), builder_budget, - &control, + control, ) { Ok(builder) => Ok(builder), Err(CodeLexicalArtifactErrorV1::Incompatible(_)) => { - store.discard_incompatible_staging(&staging_path, &control)?; + store.discard_incompatible_staging(&staging_path, control)?; CodeLexicalArtifactBuilderV1::create_with_memory_budget( &staging_path, metadata, @@ -2432,17 +2810,27 @@ impl LatestCompleteCodeIndexV1 { let progress = builder.progress().map_err(map_text_artifact_error)?; if let Some(cursor) = progress.next_cursor.as_ref() { source - .restore_cursor(cursor, &control) + .restore_cursor(cursor, control) .map_err(map_sealed_page_source_error)?; } - *build = Some(CodeTextArtifactBuildV1 { + let initialized = CodeTextArtifactBuildV1 { builder, source, sealed_identity, source_receipt: None, staging_path, _build_reservation: build_reservation, - }); + }; + self.publish_text_progress_boundary( + &initialized, + &progress, + CodeIndexBuildPhaseV1::SourceScan, + 0, + 0, + None, + true, + )?; + *build = Some(initialized); } let artifact_build = build.as_mut().ok_or_else(|| { RetrievalPortError::Contract( @@ -2450,21 +2838,171 @@ impl LatestCompleteCodeIndexV1 { ) })?; let mut remaining = maximum_work.min(TEXT_ARTIFACT_MAXIMUM_WORK_PER_ADVANCE_V1); + let mut maximum_batch_pages = remaining.min(TEXT_ARTIFACT_BATCH_PAGES_V1).max(1); + let mut maximum_batch_bytes = TEXT_ARTIFACT_BATCH_BYTES_V1; while remaining > 0 && artifact_build.source_receipt.is_none() { - let (source, builder) = (&mut artifact_build.source, &mut artifact_build.builder); - let admitted = source - .next_page_if(&control, |page| { - builder.append_page(page, &control).map(|_| ()) + maximum_batch_pages = maximum_batch_pages.min(remaining).max(1); + let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new( + maximum_batch_pages, + maximum_batch_bytes, + ) + .map_err(map_sealed_page_source_error)?; + #[cfg(feature = "hotpath")] + let completed_lexical_bytes_before = artifact_build + .source + .completed_lexical_bytes() + .map_err(map_sealed_page_source_error)?; + self.publish_text_progress_phase(CodeIndexBuildPhaseV1::SourceScan, 0, 0); + let mut durable_progress = None; + let mut commit_latency_micros = None; + let mut attempted_page_retained_bytes = Vec::new(); + let admitted = { + let (source, builder) = (&mut artifact_build.source, &mut artifact_build.builder); + source.next_page_batch_if(control, bounds, |pages| { + attempted_page_retained_bytes = pages + .iter() + .map(|page| page.retained_owned_bytes()) + .collect(); + let batch_pages = u64::try_from(pages.len()).map_err(|_| { + CodeLexicalArtifactErrorV1::Contract( + "text-artifact batch page count exceeds u64".to_owned(), + ) + })?; + let batch_payload_bytes = pages.iter().try_fold(0_u64, |total, page| { + total.checked_add(page.payload_bytes()).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "text-artifact batch payload bytes overflowed".to_owned(), + ) + }) + })?; + self.publish_text_progress_phase( + CodeIndexBuildPhaseV1::RelationalPreparation, + batch_pages, + batch_payload_bytes, + ); + let progress = hotpath::measure_block!("query.artifact.batch.builder", { + let prepared = builder.prepare_pages(pages, control)?; + self.publish_text_progress_phase( + CodeIndexBuildPhaseV1::BulkCommit, + batch_pages, + batch_payload_bytes, + ); + let commit_started = Instant::now(); + let progress = builder.append_prepared_pages(&prepared, control)?; + commit_latency_micros = Some( + u64::try_from(commit_started.elapsed().as_micros()).unwrap_or(u64::MAX), + ); + Ok::<_, CodeLexicalArtifactErrorV1>(progress) + })?; + durable_progress = Some(progress); + Ok(()) }) - .map_err(map_sealed_page_source_error)? - .map_err(map_text_artifact_error)?; + }; + let admitted = match admitted { + Ok(Ok(admitted)) => admitted, + Ok(Err(error @ CodeLexicalArtifactErrorV1::BatchTooLarge { .. })) => { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.batch.refusal_total").inc(1u64); + if attempted_page_retained_bytes.len() <= 1 { + if matches!( + &error, + CodeLexicalArtifactErrorV1::BatchTooLarge { + limit: CodeLexicalArtifactBatchLimitV1::Memory, + .. + } + ) { + self.publish_text_progress_blocked( + CodeIndexBuildBlockedReasonV1::ResidentMemory, + ); + } + return Err(map_text_artifact_error(error)); + } + maximum_batch_pages = attempted_page_retained_bytes.len() / 2; + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.batch.shrink_total").inc(1u64); + maximum_batch_bytes = maximum_batch_pages + .checked_mul(std::mem::size_of::< + crate::code_index::production::VerifiedSealedLexicalPageV1, + >()) + .and_then(|slots| { + attempted_page_retained_bytes[..maximum_batch_pages] + .iter() + .try_fold(slots, |total, bytes| total.checked_add(*bytes)) + }) + .ok_or_else(|| { + RetrievalPortError::Contract( + "reduced text-artifact batch retained bytes overflowed".to_owned(), + ) + })?; + continue; + } + Ok(Err(error @ CodeLexicalArtifactErrorV1::Unreserved(_))) => { + self.publish_text_progress_blocked( + CodeIndexBuildBlockedReasonV1::ResidentMemory, + ); + return Err(map_text_artifact_error(error)); + } + Ok(Err( + error @ (CodeLexicalArtifactErrorV1::Io(_) + | CodeLexicalArtifactErrorV1::Missing(_)), + )) => { + self.publish_text_progress_blocked( + CodeIndexBuildBlockedReasonV1::ArtifactStoreUnavailable, + ); + return Err(map_text_artifact_error(error)); + } + Ok(Err(error)) => return Err(map_text_artifact_error(error)), + Err(error) => return Err(map_sealed_page_source_error(error)), + }; match admitted { - VerifiedSealedLexicalPageReadV1::Page(page) => { - let _ = page; - remaining -= 1; + VerifiedSealedLexicalPageBatchReadV1::Pages(pages) => { + let page_count = pages.len(); + let progress = durable_progress.as_ref().ok_or_else(|| { + RetrievalPortError::Contract( + "accepted text-artifact batch has no durable builder progress" + .to_owned(), + ) + })?; + let batch_payload_bytes = pages.iter().try_fold(0_u64, |total, page| { + total.checked_add(page.payload_bytes()).ok_or_else(|| { + RetrievalPortError::Contract( + "accepted text-artifact batch payload bytes overflowed".to_owned(), + ) + }) + })?; + self.publish_text_progress_boundary( + artifact_build, + progress, + CodeIndexBuildPhaseV1::BulkCommit, + u64::try_from(page_count).unwrap_or(u64::MAX), + batch_payload_bytes, + commit_latency_micros, + true, + )?; + #[cfg(feature = "hotpath")] + { + let committed_lexical_bytes = artifact_build + .source + .completed_lexical_bytes() + .map_err(map_sealed_page_source_error)? + .saturating_sub(completed_lexical_bytes_before); + hotpath::gauge!("query.artifact.batch.committed_lexical_bytes_total") + .inc(committed_lexical_bytes); + if let Some(latency_micros) = commit_latency_micros { + hotpath::gauge!("query.artifact.progress.latest_commit_latency_micros") + .set(latency_micros); + } + } + remaining = remaining.checked_sub(page_count).ok_or_else(|| { + RetrievalPortError::Contract( + "accepted text-artifact batch exceeded its work budget".to_owned(), + ) + })?; + maximum_batch_bytes = TEXT_ARTIFACT_BATCH_BYTES_V1; } - VerifiedSealedLexicalPageReadV1::Complete(receipt) => { + VerifiedSealedLexicalPageBatchReadV1::Complete(receipt) => { artifact_build.source_receipt = Some(receipt); + self.publish_text_progress_phase(CodeIndexBuildPhaseV1::IndexBuild, 0, 0); } } } @@ -2481,16 +3019,73 @@ impl LatestCompleteCodeIndexV1 { "code text artifact finalization work budget overflowed".to_owned(), ) })?; - let finalized = artifact_build + #[cfg(feature = "hotpath")] + let finalized = if matches!( + self.text_progress_phase(), + Some(CodeIndexBuildPhaseV1::Verification) + ) { + hotpath::measure_block!("query.artifact.finalization.digest_verify_wake", { + artifact_build.builder.advance_finalization( + source_receipt, + finalization_rows, + control, + ) + }) + } else { + hotpath::measure_block!("query.artifact.index.build", { + artifact_build.builder.advance_finalization( + source_receipt, + finalization_rows, + control, + ) + }) + }; + #[cfg(not(feature = "hotpath"))] + let finalized = + artifact_build + .builder + .advance_finalization(source_receipt, finalization_rows, control); + let finalized = finalized.map_err(map_text_artifact_error)?; + let finalization_phase = match finalized { + CodeLexicalArtifactFinalizationStepV1::Pending { phase, .. } => { + let phase = match phase { + CodeLexicalArtifactFinalizationPhaseV1::IndexBuild => { + CodeIndexBuildPhaseV1::IndexBuild + } + CodeLexicalArtifactFinalizationPhaseV1::Verification => { + CodeIndexBuildPhaseV1::Verification + } + }; + let progress = artifact_build + .builder + .progress() + .map_err(map_text_artifact_error)?; + self.publish_text_progress_boundary( + artifact_build, + &progress, + phase, + 0, + 0, + None, + false, + )?; + return Ok(false); + } + CodeLexicalArtifactFinalizationStepV1::Ready(_) => CodeIndexBuildPhaseV1::Verification, + }; + let progress = artifact_build .builder - .advance_finalization(source_receipt, finalization_rows, &control) + .progress() .map_err(map_text_artifact_error)?; - if !matches!( - finalized, - tracedecay_query::retrieval::lexical::CodeLexicalArtifactFinalizationStepV1::Ready(_) - ) { - return Ok(false); - } + self.publish_text_progress_boundary( + artifact_build, + &progress, + finalization_phase, + 0, + 0, + None, + false, + )?; let finished = build.take().ok_or_else(|| { RetrievalPortError::Contract( "code-index text artifact build state vanished during publication".to_owned(), @@ -2512,7 +3107,7 @@ impl LatestCompleteCodeIndexV1 { &staging_path, self.generation.as_ref(), &sealed_identity, - &control, + control, )?; let reader_reservation = store.reserve_resident_memory( &self.generation.manifest().generation_id, @@ -2526,10 +3121,11 @@ impl LatestCompleteCodeIndexV1 { &descriptor.artifact_digest, descriptor.artifact_size_bytes, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, - &control, + control, ) .map_err(map_text_artifact_error)?; self.install_artifact_owners(reader, reader_reservation)?; + self.publish_text_progress_phase(CodeIndexBuildPhaseV1::Ready, 0, 0); drop(build_reservation); Ok(true) } @@ -2757,6 +3353,9 @@ pub(super) struct CodeIndexWorktreeSchedulerV1 { latest_content_identity: Option, ignored_source_admissions: Vec, query_owners: Mutex>, + /// Immutable generation-scoped build snapshot. The registry clones this + /// slot at mount so dashboard reads never acquire the scheduler mutex. + build_progress: CodeIndexBuildProgressSlotV1, /// Optional semantic hook: schedule `FastEmbed` projection without joining it. semantic_schedule: Option, @@ -2860,6 +3459,7 @@ impl CodeIndexWorktreeSchedulerV1 { latest_content_identity, ignored_source_admissions: Vec::new(), query_owners: Mutex::new(None), + build_progress: Arc::new(RwLock::new(CodeIndexBuildProgressSlotStateV1::default())), semantic_schedule: None, }; Ok(scheduler) @@ -2876,6 +3476,10 @@ impl CodeIndexWorktreeSchedulerV1 { self.resident_memory = resident_memory; } + pub(super) fn build_progress_slot(&self) -> CodeIndexBuildProgressSlotV1 { + Arc::clone(&self.build_progress) + } + /// Reserve the installed worker plan on the canonical process authority. /// The returned RAII guard spans source capture and the complete production /// build, releasing on success, typed failure, cancellation, or unwind. @@ -3667,34 +4271,60 @@ impl CodeIndexWorktreeSchedulerV1 { record_index, text_projection_build, text_projection_failed, + text_progress_state, + text_progress_owner_epoch, graph_activation, ) = match cached.as_ref() { - Some((cached_id, owners, index, build, failed, interactive)) - if cached_id == &generation_id => - { - ( - Arc::clone(owners), - Arc::clone(index), - Arc::clone(build), - Arc::clone(failed), - Arc::clone(interactive), - ) - } + Some(( + cached_id, + owners, + index, + build, + failed, + progress, + progress_epoch, + interactive, + )) if cached_id == &generation_id => ( + Arc::clone(owners), + Arc::clone(index), + Arc::clone(build), + Arc::clone(failed), + Arc::clone(progress), + *progress_epoch, + Arc::clone(interactive), + ), _ => { let owners = Arc::new(OnceLock::new()); let index = Arc::new(OnceLock::new()); let build = Arc::new(Mutex::new(None)); let failed = Arc::new(AtomicBool::new(false)); + let progress = Arc::new(Mutex::new(CodeIndexBuildProgressStateV1::new())); let graph_activation = Arc::new(RwLock::new(CodeGraphActivationStateV1::Pending)); + let progress_epoch = hotpath::measure_block!("query.artifact.progress.publish", { + self.build_progress + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .replace_generation(generation_id.clone()) + }); *cached = Some(( generation_id, Arc::clone(&owners), Arc::clone(&index), Arc::clone(&build), Arc::clone(&failed), + Arc::clone(&progress), + progress_epoch, Arc::clone(&graph_activation), )); - (owners, index, build, failed, graph_activation) + ( + owners, + index, + build, + failed, + progress, + progress_epoch, + graph_activation, + ) } }; LatestCompleteCodeIndexV1 { @@ -3703,6 +4333,9 @@ impl CodeIndexWorktreeSchedulerV1 { record_index, text_projection_build, text_projection_failed, + text_progress_state, + text_progress_slot: Arc::clone(&self.build_progress), + text_progress_owner_epoch, text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( &self.store_root, &self.publication, @@ -3758,24 +4391,35 @@ impl CodeIndexWorktreeSchedulerV1 { .map(|generation| { generation .filter(|generation| self.validate_generation_identity(generation).is_ok()) - .map(|generation| LatestCompleteCodeIndexV1 { - generation, - query_owners: Arc::new(OnceLock::new()), - record_index: Arc::new(OnceLock::new()), - text_projection_build: Arc::new(Mutex::new(None)), - text_projection_failed: Arc::new(AtomicBool::new(false)), - text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( - &self.store_root, - &self.publication, - &self.resident_memory, - &self.project_id, - &self.worktree_id, - ), - text_control_epoch: Arc::clone(&self.epoch), - text_control_shutdown: Arc::clone(&self.shutting_down), - graph_activation: Arc::new(RwLock::new( - CodeGraphActivationStateV1::Pending, - )), + .map(|generation| { + let generation_id = generation.manifest().generation_id.clone(); + let mut progress_slot = CodeIndexBuildProgressSlotStateV1::default(); + let text_progress_owner_epoch = + progress_slot.replace_generation(generation_id); + LatestCompleteCodeIndexV1 { + generation, + query_owners: Arc::new(OnceLock::new()), + record_index: Arc::new(OnceLock::new()), + text_projection_build: Arc::new(Mutex::new(None)), + text_projection_failed: Arc::new(AtomicBool::new(false)), + text_progress_state: Arc::new(Mutex::new( + CodeIndexBuildProgressStateV1::new(), + )), + text_progress_slot: Arc::new(RwLock::new(progress_slot)), + text_progress_owner_epoch, + text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( + &self.store_root, + &self.publication, + &self.resident_memory, + &self.project_id, + &self.worktree_id, + ), + text_control_epoch: Arc::clone(&self.epoch), + text_control_shutdown: Arc::clone(&self.shutting_down), + graph_activation: Arc::new(RwLock::new( + CodeGraphActivationStateV1::Pending, + )), + } }) }) .map_err(|error| CodeIndexProductionErrorV1::Publication(error).into()) diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 67b8068e34..ae04c2512f 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -361,6 +361,9 @@ pub(super) struct MountedCodeIndexWorktreeV1 { Option>, pub(super) scheduler: Arc>, pub(super) serving_generation: Arc>>, + /// Immutable progress snapshot independently readable while the scheduler + /// owns a long reconcile or text-artifact transaction. + pub(super) build_progress: super::CodeIndexBuildProgressSlotV1, /// Monotonic replacement epoch for the serving slot. It invalidates a /// branch-publication token even if a future worker re-seats an equal id. serving_generation_epoch: Arc, @@ -2185,6 +2188,7 @@ impl CodeIndexSchedulerRegistryV1 { let worktree_id = opened.identity().worktree_id().clone(); let reconcile_in_progress = opened.reconcile_in_progress(); let active_generation_encoded_bytes = opened.active_generation_encoded_bytes(); + let build_progress = opened.build_progress_slot(); // Cold mount publishes only the exact route. The worker may seat a // complete identity-valid generation as stale serving before refresh // claims freshness; missing Git authority still leaves this empty. @@ -2676,6 +2680,7 @@ impl CodeIndexSchedulerRegistryV1 { semantic_vector_graph_provider: None, scheduler, serving_generation, + build_progress, serving_generation_epoch, serving_generation_installation, graph_activation, @@ -3269,16 +3274,34 @@ impl CodeIndexSchedulerRegistryV1 { project_root: &Path, ) -> Option { let canonical_root = project_root.canonicalize().ok()?; - let (scheduler, reconcile_in_progress, serving_generation) = { + let (scheduler, reconcile_in_progress, serving_generation, build_progress) = { let mounted = self.mounted.lock().await; let worktree = mounted.get(&canonical_root)?; ( Arc::clone(&worktree.scheduler), Arc::clone(&worktree.reconcile_in_progress), Arc::clone(&worktree.serving_generation), + Arc::clone(&worktree.build_progress), ) }; tokio::task::spawn_blocking(move || { + let progress = hotpath::measure_block!("dashboard.code_index.progress", { + let progress = build_progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .snapshot() + .map(|snapshot| snapshot.as_ref().clone()); + #[cfg(feature = "hotpath")] + if let Some(progress) = progress.as_ref() { + let age_micros = now_micros() + .0 + .saturating_sub(progress.last_progress_micros) + .max(0); + hotpath::gauge!("dashboard.code_index.progress_age_micros") + .set(u64::try_from(age_micros).unwrap_or(u64::MAX)); + } + progress + }); let refreshing = reconcile_in_progress.load(Ordering::Acquire) != 0; let scheduler = match scheduler.try_lock() { Ok(scheduler) => scheduler, @@ -3301,6 +3324,7 @@ impl CodeIndexSchedulerRegistryV1 { ), hook_hint_count: None, coverage: "partial_refresh_in_progress".to_owned(), + progress, ..dashboard_freshness_identity(latest.as_ref()) }; } @@ -3342,6 +3366,7 @@ impl CodeIndexSchedulerRegistryV1 { "partial_hook_hint_overflow" } .to_owned(), + progress, ..dashboard_freshness_identity(latest.as_ref()) } }) diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 81b378447a..a35bc43f65 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -3,7 +3,7 @@ use std::fmt::Write as _; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Arc, OnceLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; use sha2::{Digest, Sha256}; use tempfile::TempDir; @@ -53,9 +53,9 @@ use super::registry::{ ServingGenerationRollbackOutcomeV1, }; use super::{ - CodeIndexCadenceOutcomeV1, CodeIndexCadenceTriggerV1, CodeIndexReconcileOutcomeV1, - CodeIndexSchedulerRegistryV1, CodeIndexWorktreeSchedulerV1, GenerationDecodeAdmissionV1, - SharedCodeIndexBytePoolV1, + CodeIndexBuildProgressStateV1, CodeIndexCadenceOutcomeV1, CodeIndexCadenceTriggerV1, + CodeIndexCommittedProgressSampleV1, CodeIndexReconcileOutcomeV1, CodeIndexSchedulerRegistryV1, + CodeIndexWorktreeSchedulerV1, GenerationDecodeAdmissionV1, SharedCodeIndexBytePoolV1, }; use crate::code_index::production::{CodeIndexAtomicPublicationPort, CodeIndexExecutionControlV1}; use crate::semantic_code::rerank_adapter::GenerationBoundCodeRerankViewsV1; @@ -201,6 +201,46 @@ fn scheduler( .expect("open worktree scheduler") } +fn build_progress_snapshot( + scheduler: &CodeIndexWorktreeSchedulerV1, +) -> Arc { + scheduler + .build_progress_slot() + .read() + .expect("build progress slot") + .snapshot() + .expect("published build progress") +} + +fn progress_snapshot_for_generation( + generation_id: &CodeGenerationId, + committed_pages: u64, +) -> crate::dashboard::code_index_freshness_api::CodeIndexBuildProgressV1 { + crate::dashboard::code_index_freshness_api::CodeIndexBuildProgressV1 { + generation_id: generation_id.as_str().to_owned(), + progress_epoch: 0, + sealed_source_digest: format!("sha256:{}", "a".repeat(64)), + phase: crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::BulkCommit, + committed_pages, + committed_chunks: committed_pages, + committed_imports: 0, + committed_payload_bytes: committed_pages, + completed_files: committed_pages, + total_files: 100, + completed_lexical_bytes: committed_pages, + total_lexical_bytes: 100, + current_batch_pages: 0, + current_batch_payload_bytes: 0, + elapsed_micros: 1, + last_commit_latency_micros: None, + files_per_second: None, + lexical_bytes_per_second: None, + estimated_remaining_seconds: None, + last_progress_micros: 1, + blocked_reason: None, + } +} + fn published(outcome: CodeIndexReconcileOutcomeV1) -> super::CodeIndexPublishEvidenceV1 { match outcome { CodeIndexReconcileOutcomeV1::Published(evidence) => evidence, @@ -2263,7 +2303,7 @@ fn production_text_serving_builds_publishes_and_reopens_the_artifact_head() { "pub fn caller() { callee(); }\npub fn callee() {}\n", )]); let store = TempDir::new().expect("store root"); - { + let completed_before_restart = { let mut scheduler = scheduler( &fixture, store.path().to_path_buf(), @@ -2289,7 +2329,13 @@ fn production_text_serving_builds_publishes_and_reopens_the_artifact_head() { .is_artifact_backed(), "a durable store must serve text queries from the published artifact" ); - } + let progress = build_progress_snapshot(&scheduler); + assert_eq!( + progress.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + ); + progress + }; // The durable pointer must name the published content-addressed artifact. let pointer: serde_json::Value = serde_json::from_slice( @@ -2347,6 +2393,62 @@ fn production_text_serving_builds_publishes_and_reopens_the_artifact_head() { owners.is_artifact_backed(), "the reopened owners must serve from the durable artifact" ); + let completed_after_restart = build_progress_snapshot(&scheduler); + assert_eq!( + completed_after_restart.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + ); + assert_eq!( + completed_after_restart.generation_id, + completed_before_restart.generation_id + ); + assert_eq!( + completed_after_restart.sealed_source_digest, + completed_before_restart.sealed_source_digest + ); + assert_eq!( + completed_after_restart.committed_pages, + completed_before_restart.committed_pages + ); + assert_eq!( + completed_after_restart.committed_chunks, + completed_before_restart.committed_chunks + ); + assert_eq!( + completed_after_restart.committed_imports, + completed_before_restart.committed_imports + ); + assert_eq!( + completed_after_restart.committed_payload_bytes, + completed_before_restart.committed_payload_bytes + ); + assert_eq!( + completed_after_restart.completed_files, + completed_before_restart.completed_files + ); + assert_eq!( + completed_after_restart.total_files, + completed_before_restart.total_files + ); + assert_eq!( + completed_after_restart.completed_lexical_bytes, + completed_before_restart.completed_lexical_bytes + ); + assert_eq!( + completed_after_restart.total_lexical_bytes, + completed_before_restart.total_lexical_bytes + ); + assert_eq!( + completed_after_restart.completed_files, + completed_after_restart.total_files + ); + assert_eq!( + completed_after_restart.completed_lexical_bytes, + completed_after_restart.total_lexical_bytes + ); + assert_eq!(completed_after_restart.files_per_second, None); + assert_eq!(completed_after_restart.lexical_bytes_per_second, None); + assert_eq!(completed_after_restart.estimated_remaining_seconds, None); let generation = latest.generation().manifest().generation_id.clone(); let base = RetrievalRequest { @@ -3462,20 +3564,113 @@ fn concurrent_background_wakes_share_one_generation_owned_text_builder() { } #[test] -fn scheduler_shutdown_cancels_and_resumes_the_durable_text_build() { - let sources = (0..12) - .map(|ordinal| { - ( - format!("src/file_{ordinal}.rs"), - format!("pub fn cancellation_symbol_{ordinal}() -> usize {{ {ordinal} }}\n"), - ) - }) - .collect::>(); - let borrowed = sources - .iter() - .map(|(path, source)| (path.as_str(), source.as_str())) - .collect::>(); - let fixture = GitFixture::new(&borrowed); +fn text_progress_rate_and_eta_require_two_monotonic_committed_samples() { + let mut state = CodeIndexBuildProgressStateV1::new(); + let first = Instant::now(); + state.observe_committed(CodeIndexCommittedProgressSampleV1 { + observed_at: first, + completed_files: 20, + completed_lexical_bytes: 4_000_000, + }); + assert_eq!(state.rates_and_eta(29_000_000), (None, None, None)); + + state.observe_committed(CodeIndexCommittedProgressSampleV1 { + observed_at: first + Duration::from_secs(2), + completed_files: 21, + completed_lexical_bytes: 14_000_000, + }); + let (files_per_second, lexical_bytes_per_second, eta_seconds) = state.rates_and_eta(29_000_000); + assert_eq!(files_per_second, Some(0.5)); + assert_eq!(lexical_bytes_per_second, Some(5_000_000.0)); + assert_eq!( + eta_seconds, + Some(3), + "ETA must use the multi-page lexical span, not uneven completed-file boundaries" + ); +} + +#[test] +fn progress_owner_epoch_rejects_original_a_after_a_b_a_replacement() { + let generation_a = CodeGenerationId::new("generation.progress-a").expect("generation A"); + let generation_b = CodeGenerationId::new("generation.progress-b").expect("generation B"); + let mut slot = super::CodeIndexBuildProgressSlotStateV1::default(); + + let original_a_owner = slot.replace_generation(generation_a.clone()); + assert!(slot.publish( + &generation_a, + original_a_owner, + progress_snapshot_for_generation(&generation_a, 1), + )); + + let generation_b_owner = slot.replace_generation(generation_b.clone()); + assert!(slot.publish( + &generation_b, + generation_b_owner, + progress_snapshot_for_generation(&generation_b, 2), + )); + + let replacement_a_owner = slot.replace_generation(generation_a.clone()); + assert_ne!(replacement_a_owner, original_a_owner); + assert!(slot.publish( + &generation_a, + replacement_a_owner, + progress_snapshot_for_generation(&generation_a, 3), + )); + let replacement_a = slot.snapshot().expect("replacement A snapshot"); + + assert!(!slot.publish( + &generation_a, + original_a_owner, + progress_snapshot_for_generation(&generation_a, 99), + )); + let after_stale_original = slot.snapshot().expect("current A snapshot"); + assert_eq!(after_stale_original.generation_id, generation_a.as_str()); + assert_eq!(after_stale_original.committed_pages, 3); + assert_eq!( + after_stale_original.progress_epoch, replacement_a.progress_epoch, + "matching generation identity must not let the original A owner replace the newer A epoch" + ); +} + +#[test] +fn dashboard_progress_advances_only_after_durable_batch_commit() { + struct CancelAfterPreparation { + progress: super::CodeIndexBuildProgressSlotV1, + observed_bulk_commit: std::sync::atomic::AtomicBool, + } + + impl CodeIndexExecutionControlV1 for CancelAfterPreparation { + fn is_cancelled(&self) -> bool { + let cancel = self + .progress + .read() + .expect("cancellation progress slot") + .snapshot() + .is_some_and(|snapshot| { + snapshot.phase + == crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::BulkCommit + }); + if cancel { + self.observed_bulk_commit + .store(true, std::sync::atomic::Ordering::Release); + } + cancel + } + + fn is_deadline_exceeded(&self) -> bool { + false + } + } + + let mut source = String::new(); + for ordinal in 0..600 { + writeln!( + source, + "pub fn cancellation_symbol_{ordinal}() -> usize {{ {ordinal} }}" + ) + .expect("write cancellation fixture"); + } + let fixture = GitFixture::new(&[("src/lib.rs", source.as_str())]); let store = TempDir::new().expect("store root"); let mut scheduler = scheduler( &fixture, @@ -3498,14 +3693,43 @@ fn scheduler_shutdown_cancels_and_resumes_the_durable_text_build() { .builder .progress() .expect("durable progress"); + let dashboard_before = build_progress_snapshot(&scheduler); + assert_eq!( + dashboard_before.committed_pages, + progress_before.next_page_ordinal + ); + assert_eq!(dashboard_before.committed_pages, 1); + assert!( + dashboard_before.completed_files < dashboard_before.total_files, + "the fixture must retain more authenticated source work after its first page" + ); - latest - .text_control_shutdown - .store(true, std::sync::atomic::Ordering::Release); + let control = CancelAfterPreparation { + progress: scheduler.build_progress_slot(), + observed_bulk_commit: std::sync::atomic::AtomicBool::new(false), + }; + let cancellation_started = Instant::now(); + let cancellation = { + let mut build = latest + .text_projection_build + .lock() + .expect("text build state for controlled cancellation"); + latest.advance_artifact_text_serving(&mut build, 1, &control) + }; assert_eq!( - latest.advance_text_serving(1), + cancellation, Err(tracedecay_query::retrieval::RetrievalPortError::Cancelled) ); + assert!( + cancellation_started.elapsed() < Duration::from_secs(1), + "a cancelled scheduler batch must yield within the focused latency bound" + ); + assert!( + control + .observed_bulk_commit + .load(std::sync::atomic::Ordering::Acquire), + "cancellation must occur after preparation publishes the bulk-commit boundary" + ); let progress_after = latest .text_projection_build .lock() @@ -3516,16 +3740,93 @@ fn scheduler_shutdown_cancels_and_resumes_the_durable_text_build() { .progress() .expect("durable progress after cancellation"); assert_eq!(progress_after, progress_before); + let dashboard_after = build_progress_snapshot(&scheduler); + assert_eq!( + dashboard_after.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::BulkCommit + ); + assert_eq!(dashboard_after.current_batch_pages, 1); + assert_eq!( + dashboard_after.committed_pages, dashboard_before.committed_pages, + "a prepared but cancelled batch must not publish staged pages as committed" + ); + assert_eq!( + dashboard_after.completed_lexical_bytes, dashboard_before.completed_lexical_bytes, + "a cancelled batch must retain the prior exact sealed-source boundary" + ); assert!(latest.text_serving_needs_work()); - latest - .text_control_shutdown - .store(false, std::sync::atomic::Ordering::Release); while !latest .advance_text_serving(8) .expect("resume durable text build") {} assert!(latest.query_owners_are_warm()); + let dashboard_ready = build_progress_snapshot(&scheduler); + assert_eq!( + dashboard_ready.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + ); + assert!(dashboard_ready.progress_epoch > dashboard_after.progress_epoch); +} + +#[test] +fn reopen_reconstructs_exact_committed_progress_without_fabricating_rate() { + let sources = (0..12) + .map(|ordinal| { + ( + format!("src/reopen_{ordinal}.rs"), + format!("pub fn reopen_symbol_{ordinal}() -> usize {{ {ordinal} }}\n"), + ) + }) + .collect::>(); + let borrowed = sources + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect::>(); + let fixture = GitFixture::new(&borrowed); + let store = TempDir::new().expect("store root"); + let bytes = Arc::new(SharedCodeIndexBytePoolV1::default()); + let committed = { + let mut scheduler = scheduler(&fixture, store.path().to_path_buf(), Arc::clone(&bytes)); + published(scheduler.reconcile_now().expect("publish generation")); + let latest = scheduler.latest_complete().expect("latest generation"); + assert!( + !latest + .advance_text_serving(1) + .expect("commit one bounded source batch") + ); + let progress = build_progress_snapshot(&scheduler); + assert!(progress.committed_pages > 0); + assert_eq!(progress.files_per_second, None); + assert_eq!(progress.lexical_bytes_per_second, None); + assert_eq!(progress.estimated_remaining_seconds, None); + progress + }; + + let reopened = scheduler(&fixture, store.path().to_path_buf(), bytes); + let latest = reopened.latest_complete().expect("reopened generation"); + assert!( + !latest + .advance_text_serving(0) + .expect("reconstruct durable cursor snapshot") + ); + let reconstructed = build_progress_snapshot(&reopened); + assert_eq!(reconstructed.generation_id, committed.generation_id); + assert_eq!(reconstructed.committed_pages, committed.committed_pages); + assert_eq!(reconstructed.committed_chunks, committed.committed_chunks); + assert_eq!(reconstructed.committed_imports, committed.committed_imports); + assert_eq!( + reconstructed.committed_payload_bytes, + committed.committed_payload_bytes + ); + assert_eq!(reconstructed.completed_files, committed.completed_files); + assert_eq!( + reconstructed.completed_lexical_bytes, + committed.completed_lexical_bytes + ); + assert_eq!(reconstructed.files_per_second, None); + assert_eq!(reconstructed.lexical_bytes_per_second, None); + assert_eq!(reconstructed.estimated_remaining_seconds, None); } #[test] @@ -3570,6 +3871,7 @@ fn generation_replacement_drops_incomplete_text_projection_state() { .advance_text_serving(1) .expect("start original text projection") ); + let original_progress = build_progress_snapshot(&scheduler); let original_state = Arc::downgrade(&original.text_projection_build); let original_generation = original.generation().manifest().generation_id.clone(); @@ -3588,6 +3890,41 @@ fn generation_replacement_drops_incomplete_text_projection_state() { &original.text_projection_build, &replacement.text_projection_build )); + assert!( + scheduler + .build_progress_slot() + .read() + .expect("replacement progress slot") + .snapshot() + .is_none(), + "generation replacement clears the superseded snapshot before new work begins" + ); + assert!( + !replacement + .advance_text_serving(0) + .expect("publish replacement progress baseline") + ); + let replacement_progress = build_progress_snapshot(&scheduler); + assert_eq!( + replacement_progress.generation_id, + replacement.generation().manifest().generation_id.as_str() + ); + assert!(replacement_progress.progress_epoch > original_progress.progress_epoch); + original.publish_text_progress_phase( + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::BulkCommit, + 99, + 99, + ); + let progress_after_stale_publish = build_progress_snapshot(&scheduler); + assert_eq!( + progress_after_stale_publish.generation_id, + replacement_progress.generation_id + ); + assert_eq!( + progress_after_stale_publish.progress_epoch, replacement_progress.progress_epoch, + "the old generation/owner epoch must not replace a newer generation snapshot" + ); + assert_ne!(progress_after_stale_publish.current_batch_pages, 99); drop(original); assert!( original_state.upgrade().is_none(), @@ -4807,6 +5144,71 @@ async fn dashboard_freshness_projects_the_mounted_scheduler_generation() { assert_eq!(projected.coverage, "complete"); } +#[tokio::test] +async fn dashboard_progress_does_not_wait_for_the_scheduler_mutex() { + let sources = (0..12) + .map(|ordinal| { + ( + format!("src/dashboard_{ordinal}.rs"), + format!("pub fn dashboard_symbol_{ordinal}() -> usize {{ {ordinal} }}\n"), + ) + }) + .collect::>(); + let borrowed = sources + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect::>(); + let fixture = GitFixture::new(&borrowed); + let store = TempDir::new().expect("store root"); + let registry = CodeIndexSchedulerRegistryV1::new(1); + registry + .mount_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + ) + .await + .expect("mount daemon-owned scheduler"); + wait_for_initial_generation(®istry, fixture.path()).await; + let canonical_root = fixture + .path() + .canonicalize() + .expect("canonical fixture root"); + let (scheduler, progress_slot) = { + let mounted = registry.mounted.lock().await; + let worktree = mounted.get(&canonical_root).expect("mounted worktree"); + ( + Arc::clone(&worktree.scheduler), + Arc::clone(&worktree.build_progress), + ) + }; + let expected = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(progress) = progress_slot.read().expect("progress slot").snapshot() { + break progress; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("background text build publishes progress"); + + let scheduler_guard = scheduler.lock().expect("hold scheduler mutex"); + let projected = tokio::time::timeout( + Duration::from_secs(1), + registry.dashboard_freshness(fixture.path()), + ) + .await + .expect("dashboard projection must not wait for scheduler") + .expect("mounted freshness projection"); + let projected_progress = projected.progress.expect("projected progress snapshot"); + assert_eq!(projected_progress.generation_id, expected.generation_id); + assert!(projected_progress.progress_epoch >= expected.progress_epoch); + drop(scheduler_guard); + registry.shutdown().await; +} + /// A dashboard status view reports the last execution-owned scheduler state; it /// must not run the freshness ladder, wake a worker, or publish an out-of-band /// source change merely because an operator opened the view. From 6ba1a762f33aa0e222f6cbf2f5bd2ce603452295 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 01:31:21 +0000 Subject: [PATCH 09/69] fix(dashboard): retain current catch-up progress --- .../code/IndexFreshness.dom.test.tsx | 51 +++++++ .../src/workspaces/code/IndexFreshness.tsx | 11 +- .../observatory/ObservatoryPage.dom.test.tsx | 124 +++++++++++++++++- .../observatory/ObservatoryPage.tsx | 88 ++++++++++--- 4 files changed, 247 insertions(+), 27 deletions(-) diff --git a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx index 18923b0e3a..1666a5667a 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx @@ -212,6 +212,57 @@ describe('Code index freshness', () => { expect(fetch).toHaveBeenCalledTimes(3); }); + it('accepts a same-generation publication after restart and rejects a delayed pre-restart epoch', async () => { + vi.useFakeTimers(); + const beforeRestart = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { ...progress(), progress_epoch: 8 }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const afterRestart = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { + ...progress(), + progress_epoch: 0, + last_progress_micros: NOW_MICROS + 1, + completed_files: 1, + }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(beforeRestart), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(afterRestart), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(beforeRestart), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('250 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('1 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('250 / 500 files')).toBeNull(); + }); + it('polls an active build each second and returns to the ready cadence', async () => { vi.useFakeTimers(); const active = envelope('loading', { diff --git a/dashboard/src/workspaces/code/IndexFreshness.tsx b/dashboard/src/workspaces/code/IndexFreshness.tsx index 913230bd65..56bbc281d8 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.tsx @@ -277,8 +277,17 @@ function isCurrentOrNewerProgress( incoming: CodeIndexBuildProgress, rendered: CodeIndexBuildProgress, ): boolean { + if (incoming.last_progress_micros < rendered.last_progress_micros) { + return false; + } if (incoming.generation_id === rendered.generation_id) { - return incoming.progress_epoch >= rendered.progress_epoch; + if (incoming.progress_epoch >= rendered.progress_epoch) { + return true; + } + // A lower epoch is only credible after a daemon restart. The durable + // last-progress timestamp establishes that new process boundary and keeps + // delayed publications from the preceding process out of this view. + return incoming.last_progress_micros > rendered.last_progress_micros; } // Epochs restart with the daemon. A different generation is only accepted // when its own immutable publication is later; an equal or older delayed diff --git a/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx b/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx index 7494687863..acb71c4d52 100644 --- a/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx +++ b/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx @@ -1,6 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useScope } from '../../data/scope/store.ts'; import { ObservatoryPage } from './ObservatoryPage.tsx'; /** @@ -28,6 +29,7 @@ describe('ObservatoryPage store telemetry', () => { }); afterEach(() => { + useScope.getState().selectAllProjects(); vi.useRealTimers(); }); @@ -120,6 +122,115 @@ describe('ObservatoryPage store telemetry', () => { expect(screen.queryByText('generation.catchup.01')).toBeNull(); }); + it('accepts a same-generation publication after restart and rejects a delayed pre-restart epoch', async () => { + vi.useFakeTimers(); + const beforeRestart = codeIndexFreshnessEnvelope(); + const beforeRestartWorktree = beforeRestart.payload.worktrees[0]!; + const beforeRestartProgress = beforeRestartWorktree.progress; + const afterRestart = { + ...beforeRestart, + payload: { + ...beforeRestart.payload, + worktrees: [ + { + ...beforeRestartWorktree, + progress: { + ...beforeRestartProgress, + progress_epoch: 0, + last_progress_micros: SAMPLE_CURRENT_MICROS + 1, + completed_files: 1, + }, + }, + ], + }, + }; + const beforeRestartWithEpoch = { + ...beforeRestart, + payload: { + ...beforeRestart.payload, + worktrees: [ + { + ...beforeRestartWorktree, + progress: { ...beforeRestartProgress, progress_epoch: 8 }, + }, + ], + }, + }; + const progressResponses = [beforeRestartWithEpoch, afterRestart, beforeRestartWithEpoch]; + let progressResponse = 0; + stubTelemetry( + telemetryPayload(), + emptyStorageFindingsPayload(), + () => progressResponses[Math.min(progressResponse++, progressResponses.length - 1)]!, + ); + renderObservatory(); + + await advanceTimers(0); + expect(screen.getByText('250 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('1 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('250 / 500 files')).toBeNull(); + }); + + it('removes an unmounted worktree before switching to the next scope pipeline', async () => { + vi.useFakeTimers(); + const first = codeIndexFreshnessEnvelope(); + const firstWorktree = first.payload.worktrees[0]!; + const firstProgress = firstWorktree.progress; + const aggregate = { + ...first, + payload: { + ...first.payload, + worktrees: [ + { + ...firstWorktree, + worktree_root: '/worktrees/aggregate-alpha', + progress: { ...firstProgress, generation_id: 'generation.scope.alpha' }, + }, + ], + }, + }; + const unmounted = { + ...aggregate, + payload: { ...aggregate.payload, worktrees: [] }, + }; + const projectBeta = { + ...aggregate, + scope: { project_id: 'project.beta', storage_mode: 'project', store_root: '/stores/beta' }, + payload: { + ...aggregate.payload, + worktrees: [ + { + ...firstWorktree, + worktree_root: '/worktrees/project-beta', + progress: { ...firstProgress, generation_id: 'generation.scope.beta' }, + }, + ], + }, + }; + let aggregateResponse = 0; + stubTelemetry(telemetryPayload(), emptyStorageFindingsPayload(), (url: string) => { + if (url.startsWith('/api/projects/project.beta/')) return projectBeta; + return [aggregate, unmounted][Math.min(aggregateResponse++, 1)]!; + }); + renderObservatory(); + + await advanceTimers(0); + expect(screen.getByText('generation.scope.alpha')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('generation.scope.alpha')).toBeNull(); + + act(() => useScope.getState().selectProject('project.beta', 'Project Beta', 'selected')); + await advanceTimers(0); + expect(screen.getByText('generation.scope.beta')).toBeTruthy(); + expect(screen.queryByText('generation.scope.alpha')).toBeNull(); + expect(document.querySelectorAll('[data-code-index-generation]').length).toBe(1); + }); + it('distinguishes an unset budget from an undetermined one in the rendered state', async () => { stubTelemetry(telemetryPayload()); renderObservatory(); @@ -342,24 +453,25 @@ async function advanceTimers(milliseconds: number): Promise { function stubTelemetry( payload: unknown, findingsPayload: unknown = emptyStorageFindingsPayload(), - codeIndexFreshnessPayload: unknown | (() => unknown) = readyCodeIndexFreshnessEnvelope(), + codeIndexFreshnessPayload: unknown | ((url: string) => unknown) = readyCodeIndexFreshnessEnvelope(), ) { vi.stubGlobal( 'fetch', vi.fn(async (input: RequestInfo | URL) => { const url = String(input); - if (url === '/api/storage/telemetry') return jsonResponse(envelope(payload)); - if (url === '/api/storage/findings') { + const route = url.replace(/^\/api\/projects\/[^/]+/, '/api'); + if (route === '/api/storage/telemetry') return jsonResponse(envelope(payload)); + if (route === '/api/storage/findings') { return jsonResponse(envelope(findingsPayload)); } - if (url === '/api/code-index/freshness') { + if (route === '/api/code-index/freshness') { return jsonResponse( typeof codeIndexFreshnessPayload === 'function' - ? codeIndexFreshnessPayload() + ? codeIndexFreshnessPayload(url) : codeIndexFreshnessPayload, ); } - if (url === '/api/doctor/findings') { + if (route === '/api/doctor/findings') { return jsonResponse( envelope({ family_filter: null, diff --git a/dashboard/src/workspaces/observatory/ObservatoryPage.tsx b/dashboard/src/workspaces/observatory/ObservatoryPage.tsx index 64377501a0..28947a28ec 100644 --- a/dashboard/src/workspaces/observatory/ObservatoryPage.tsx +++ b/dashboard/src/workspaces/observatory/ObservatoryPage.tsx @@ -60,14 +60,15 @@ import type { ObservatoryAccountingReads } from './accountingReads.ts'; * read models. A failed source never hides the other source or becomes empty. */ export function ObservatoryPage() { const scope = useScope((s) => s.scope); + const codeIndexScopeKey = scopeKey(scope); const telemetry = useQuery({ - queryKey: ['storage', 'telemetry', scopeKey(scope)], + queryKey: ['storage', 'telemetry', codeIndexScopeKey], queryFn: () => fetchEnvelope(scopedUrl(scope, '/api/storage/telemetry'), StorageTelemetryPayloadV1Schema), refetchInterval: 30_000, }); const codeIndexFreshness = useQuery({ - queryKey: ['code-index', 'freshness', scopeKey(scope)], + queryKey: ['code-index', 'freshness', codeIndexScopeKey], queryFn: () => fetchEnvelope(scopedUrl(scope, '/api/code-index/freshness'), CodeIndexFreshnessPayloadV1Schema), refetchInterval: (query) => (hasActiveCodeIndexBuild(query.state.data) ? 1_000 : 30_000), @@ -145,6 +146,7 @@ export function ObservatoryPage() { | undefined; pending: boolean; + scopeKey: string; }) { - const progress = useLatestCodeIndexProgress(result); + const progress = useLatestCodeIndexProgress(result, scopeKey); if (pending) { return (
@@ -296,44 +300,88 @@ function hasActiveCodeIndexBuild( function useLatestCodeIndexProgress( result: EnvelopeResult | undefined, + currentScopeKey: string, ): readonly CodeIndexBuildProgressV1[] { - const [latestProgress, setLatestProgress] = useState>( - () => new Map(), - ); + const [latestProgress, setLatestProgress] = useState(() => ({ + scopeKey: currentScopeKey, + byWorktree: new Map(), + })); useEffect(() => { - if (result?.outcome !== 'envelope') return; + if (result?.outcome !== 'envelope') { + setLatestProgress((rendered) => + rendered.scopeKey === currentScopeKey + ? rendered + : { scopeKey: currentScopeKey, byWorktree: new Map() }, + ); + return; + } setLatestProgress((rendered) => { - const next = new Map(rendered); - let changed = false; + const current = + rendered.scopeKey === currentScopeKey + ? rendered.byWorktree + : new Map(); + const next = new Map(); for (const worktree of result.envelope.payload.worktrees) { const incoming = worktree.progress; - const current = next.get(worktree.worktree_root); + const renderedProgress = current.get(worktree.worktree_root); if (!incoming) { if ( - current && + renderedProgress && result.envelope.domain_state === 'ready' && - worktree.latest_generation_id === current.generation_id + worktree.latest_generation_id === renderedProgress.generation_id ) { - next.delete(worktree.worktree_root); - changed = true; + continue; } - } else if (!current || isCurrentOrNewerCodeIndexProgress(incoming, current)) { + if (renderedProgress) next.set(worktree.worktree_root, renderedProgress); + } else if ( + !renderedProgress || + isCurrentOrNewerCodeIndexProgress(incoming, renderedProgress) + ) { next.set(worktree.worktree_root, incoming); - changed = true; + } else { + next.set(worktree.worktree_root, renderedProgress); } } - return changed ? next : rendered; + return rendered.scopeKey === currentScopeKey && + sameCodeIndexProgressMap(next, rendered.byWorktree) + ? rendered + : { scopeKey: currentScopeKey, byWorktree: next }; }); - }, [result]); - return [...latestProgress.values()]; + }, [currentScopeKey, result]); + return latestProgress.scopeKey === currentScopeKey ? [...latestProgress.byWorktree.values()] : []; +} + +interface ScopedCodeIndexProgress { + scopeKey: string; + byWorktree: ReadonlyMap; +} + +function sameCodeIndexProgressMap( + left: ReadonlyMap, + right: ReadonlyMap, +): boolean { + if (left.size !== right.size) return false; + for (const [worktreeRoot, progress] of left) { + if (right.get(worktreeRoot) !== progress) return false; + } + return true; } function isCurrentOrNewerCodeIndexProgress( incoming: CodeIndexBuildProgressV1, rendered: CodeIndexBuildProgressV1, ): boolean { + if (incoming.last_progress_micros < rendered.last_progress_micros) { + return false; + } if (incoming.generation_id === rendered.generation_id) { - return incoming.progress_epoch >= rendered.progress_epoch; + if (incoming.progress_epoch >= rendered.progress_epoch) { + return true; + } + // A lower epoch is only credible after a daemon restart. The durable + // last-progress timestamp establishes that new process boundary and keeps + // delayed publications from the preceding process out of this view. + return incoming.last_progress_micros > rendered.last_progress_micros; } // A daemon restart resets its in-memory epoch, so replacement generations // order by their immutable publication time. An older delayed response is From 9f36b910bb6d3c70f811d4bc7a3d7ebe1bec903a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 01:46:07 +0000 Subject: [PATCH 10/69] fix(dashboard): order code index progress by incarnation --- .../code/IndexFreshness.dom.test.tsx | 7 ++- .../src/workspaces/code/IndexFreshness.tsx | 18 +----- .../observatory/ObservatoryPage.dom.test.tsx | 55 ++++++++++++++++++- .../observatory/ObservatoryPage.tsx | 18 +----- 4 files changed, 64 insertions(+), 34 deletions(-) diff --git a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx index 1666a5667a..924628c11a 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx @@ -185,8 +185,9 @@ describe('Code index freshness', () => { generation_id: 'generation.catchup.02', // The daemon restarted before this generation began, so its // in-memory progress epoch is lower than the rendered generation. + producer_incarnation: 2, progress_epoch: 0, - last_progress_micros: NOW_MICROS + 1, + last_progress_micros: NOW_MICROS - 1, completed_files: 1, }, }, @@ -237,8 +238,9 @@ describe('Code index freshness', () => { staleness_state: 'indexing', progress: { ...progress(), + producer_incarnation: 2, progress_epoch: 0, - last_progress_micros: NOW_MICROS + 1, + last_progress_micros: NOW_MICROS - 1, completed_files: 1, }, }, @@ -378,6 +380,7 @@ function worktree() { function progress() { return { generation_id: 'generation.catchup.01', + producer_incarnation: 1, progress_epoch: 1, sealed_source_digest: 'sha256:sealed-source-catchup', phase: 'bulk_commit', diff --git a/dashboard/src/workspaces/code/IndexFreshness.tsx b/dashboard/src/workspaces/code/IndexFreshness.tsx index 56bbc281d8..b100af8d3f 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.tsx @@ -277,22 +277,10 @@ function isCurrentOrNewerProgress( incoming: CodeIndexBuildProgress, rendered: CodeIndexBuildProgress, ): boolean { - if (incoming.last_progress_micros < rendered.last_progress_micros) { - return false; + if (incoming.producer_incarnation !== rendered.producer_incarnation) { + return incoming.producer_incarnation > rendered.producer_incarnation; } - if (incoming.generation_id === rendered.generation_id) { - if (incoming.progress_epoch >= rendered.progress_epoch) { - return true; - } - // A lower epoch is only credible after a daemon restart. The durable - // last-progress timestamp establishes that new process boundary and keeps - // delayed publications from the preceding process out of this view. - return incoming.last_progress_micros > rendered.last_progress_micros; - } - // Epochs restart with the daemon. A different generation is only accepted - // when its own immutable publication is later; an equal or older delayed - // response has no durable evidence that it supersedes the rendered build. - return incoming.last_progress_micros > rendered.last_progress_micros; + return incoming.progress_epoch >= rendered.progress_epoch; } function progressPercentage(progress: CodeIndexBuildProgress): number { diff --git a/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx b/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx index acb71c4d52..7e7e70a91c 100644 --- a/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx +++ b/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx @@ -95,8 +95,9 @@ describe('ObservatoryPage store telemetry', () => { progress: { ...firstProgress, generation_id: 'generation.catchup.02', + producer_incarnation: 2, progress_epoch: 0, - last_progress_micros: SAMPLE_CURRENT_MICROS + 1, + last_progress_micros: SAMPLE_CURRENT_MICROS - 1, completed_files: 1, }, }, @@ -136,8 +137,9 @@ describe('ObservatoryPage store telemetry', () => { ...beforeRestartWorktree, progress: { ...beforeRestartProgress, + producer_incarnation: 2, progress_epoch: 0, - last_progress_micros: SAMPLE_CURRENT_MICROS + 1, + last_progress_micros: SAMPLE_CURRENT_MICROS - 1, completed_files: 1, }, }, @@ -231,6 +233,54 @@ describe('ObservatoryPage store telemetry', () => { expect(document.querySelectorAll('[data-code-index-generation]').length).toBe(1); }); + it('clears an active scope pipeline before accepting a direct active scope replacement', async () => { + vi.useFakeTimers(); + const first = codeIndexFreshnessEnvelope(); + const firstWorktree = first.payload.worktrees[0]!; + const firstProgress = firstWorktree.progress; + const projectAlpha = { + ...first, + scope: { project_id: 'project.alpha', storage_mode: 'project', store_root: '/stores/alpha' }, + payload: { + ...first.payload, + worktrees: [ + { + ...firstWorktree, + worktree_root: '/worktrees/project-alpha', + progress: { ...firstProgress, generation_id: 'generation.scope.alpha' }, + }, + ], + }, + }; + const projectBeta = { + ...projectAlpha, + scope: { project_id: 'project.beta', storage_mode: 'project', store_root: '/stores/beta' }, + payload: { + ...projectAlpha.payload, + worktrees: [ + { + ...firstWorktree, + worktree_root: '/worktrees/project-beta', + progress: { ...firstProgress, generation_id: 'generation.scope.beta' }, + }, + ], + }, + }; + stubTelemetry(telemetryPayload(), emptyStorageFindingsPayload(), (url: string) => + url.startsWith('/api/projects/project.alpha/') ? projectAlpha : projectBeta, + ); + act(() => useScope.getState().selectProject('project.alpha', 'Project Alpha', 'active')); + renderObservatory(); + + await advanceTimers(0); + expect(screen.getByText('generation.scope.alpha')).toBeTruthy(); + act(() => useScope.getState().selectProject('project.beta', 'Project Beta', 'active')); + await advanceTimers(0); + expect(screen.getByText('generation.scope.beta')).toBeTruthy(); + expect(screen.queryByText('generation.scope.alpha')).toBeNull(); + expect(document.querySelectorAll('[data-code-index-generation]').length).toBe(1); + }); + it('distinguishes an unset budget from an undetermined one in the rendered state', async () => { stubTelemetry(telemetryPayload()); renderObservatory(); @@ -513,6 +563,7 @@ function codeIndexFreshnessEnvelope() { staleness_state: 'indexing', progress: { generation_id: 'generation.catchup.01', + producer_incarnation: 1, progress_epoch: 1, sealed_source_digest: 'sha256:sealed-source-catchup', phase: 'bulk_commit', diff --git a/dashboard/src/workspaces/observatory/ObservatoryPage.tsx b/dashboard/src/workspaces/observatory/ObservatoryPage.tsx index 28947a28ec..2ae2a1eccd 100644 --- a/dashboard/src/workspaces/observatory/ObservatoryPage.tsx +++ b/dashboard/src/workspaces/observatory/ObservatoryPage.tsx @@ -371,22 +371,10 @@ function isCurrentOrNewerCodeIndexProgress( incoming: CodeIndexBuildProgressV1, rendered: CodeIndexBuildProgressV1, ): boolean { - if (incoming.last_progress_micros < rendered.last_progress_micros) { - return false; + if (incoming.producer_incarnation !== rendered.producer_incarnation) { + return incoming.producer_incarnation > rendered.producer_incarnation; } - if (incoming.generation_id === rendered.generation_id) { - if (incoming.progress_epoch >= rendered.progress_epoch) { - return true; - } - // A lower epoch is only credible after a daemon restart. The durable - // last-progress timestamp establishes that new process boundary and keeps - // delayed publications from the preceding process out of this view. - return incoming.last_progress_micros > rendered.last_progress_micros; - } - // A daemon restart resets its in-memory epoch, so replacement generations - // order by their immutable publication time. An older delayed response is - // not evidence of a later generation and stays rejected. - return incoming.last_progress_micros > rendered.last_progress_micros; + return incoming.progress_epoch >= rendered.progress_epoch; } function codeIndexProgressPercentage(progress: CodeIndexBuildProgressV1): number { From 5a87150199c14826b22990f4bf4d7ee2bf807766 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 01:49:45 +0000 Subject: [PATCH 11/69] fix(index): order progress by daemon incarnation --- .../src/code_index_freshness_api.rs | 7 +++++++ .../schemas/dashboard-contracts.schema.json | 7 +++++++ dashboard/src/contracts/generated.ts | 1 + src/daemon/bootstrap.rs | 4 +++- src/daemon/code_index_scheduler.rs | 14 +++++++++++++ src/daemon/code_index_scheduler/registry.rs | 6 ++++++ .../registry/resident_memory.rs | 17 +++++++++++++++- src/daemon/code_index_scheduler/tests.rs | 1 + src/daemon/engine.rs | 6 ++++++ src/daemon/invocation_state.rs | 20 ++++++++++++------- 10 files changed, 74 insertions(+), 9 deletions(-) diff --git a/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs b/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs index 2870e9c66f..73d5da8ba8 100644 --- a/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs +++ b/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs @@ -58,6 +58,12 @@ pub enum CodeIndexBuildBlockedReasonV1 { pub struct CodeIndexBuildProgressV1 { /// Exact generation receiving the committed build work. pub generation_id: String, + /// Durable daemon-authority epoch that produced this snapshot. + /// + /// This orders snapshots across daemon restarts without relying on wall + /// clock time. `progress_epoch` is comparable only within one producer + /// incarnation. + pub producer_incarnation: u64, /// Monotonic publication epoch for replacing delayed progress reads. pub progress_epoch: u64, /// Identity of the sealed source whose authenticated bounds define progress. @@ -348,6 +354,7 @@ mod tests { coverage: "complete".to_owned(), progress: Some(CodeIndexBuildProgressV1 { generation_id: "generation.catchup.01".to_owned(), + producer_incarnation: 11, progress_epoch: 7, sealed_source_digest: "sha256:sealed-source-catchup".to_owned(), phase: CodeIndexBuildPhaseV1::BulkCommit, diff --git a/dashboard/codegen/schemas/dashboard-contracts.schema.json b/dashboard/codegen/schemas/dashboard-contracts.schema.json index 12b34eba46..4fc46d76ad 100644 --- a/dashboard/codegen/schemas/dashboard-contracts.schema.json +++ b/dashboard/codegen/schemas/dashboard-contracts.schema.json @@ -2304,6 +2304,12 @@ "$ref": "#/$defs/CodeIndexBuildPhaseV1", "description": "Durable pipeline phase that published this snapshot." }, + "producer_incarnation": { + "description": "Durable daemon-authority epoch that produced this snapshot.\n\nThis orders snapshots across daemon restarts without relying on wall\nclock time. `progress_epoch` is comparable only within one producer\nincarnation.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, "progress_epoch": { "description": "Monotonic publication epoch for replacing delayed progress reads.", "format": "uint64", @@ -2329,6 +2335,7 @@ }, "required": [ "generation_id", + "producer_incarnation", "progress_epoch", "sealed_source_digest", "phase", diff --git a/dashboard/src/contracts/generated.ts b/dashboard/src/contracts/generated.ts index 3e17abcf35..386e07a949 100644 --- a/dashboard/src/contracts/generated.ts +++ b/dashboard/src/contracts/generated.ts @@ -669,6 +669,7 @@ export const CodeIndexBuildProgressV1Schema = z.object({ last_progress_micros: z.number().int().safe(), lexical_bytes_per_second: z.number().nullable(), phase: z.lazy(() => CodeIndexBuildPhaseV1Schema), + producer_incarnation: z.number().int().safe().min(0), progress_epoch: z.number().int().safe().min(0), sealed_source_digest: z.string(), total_files: z.number().int().safe().min(0), diff --git a/src/daemon/bootstrap.rs b/src/daemon/bootstrap.rs index 5bf6fb94f1..87dab34ba4 100644 --- a/src/daemon/bootstrap.rs +++ b/src/daemon/bootstrap.rs @@ -110,7 +110,8 @@ pub async fn run_foreground( let store_administration = StoreAdministration::default().with_profile_identity(authority.profile_identity().clone()); let project_open_gates = Arc::new(tokio::sync::Mutex::new(ProjectOpenGates::default())); - let invocation = DaemonInvocationState::default(); + let invocation = + DaemonInvocationState::with_progress_producer_incarnation(authority.record().epoch); store_administration .configure_codex_preparation_resources( invocation.code_index_schedulers.process_resident_memory(), @@ -497,6 +498,7 @@ async fn run_foreground_unix( )?; let http_application_registry = http_application::DaemonHttpApplicationRegistry::default(); let engine = DaemonEngine::default() + .with_progress_producer_incarnation(authority.record().epoch) .with_profile_identity(authority.profile_identity().clone()) .with_http_application_registry(http_application_registry.clone()); engine diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index a5b0670384..06a9c50f2d 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -1705,6 +1705,9 @@ pub(in crate::daemon) struct LatestCompleteCodeIndexV1 { text_progress_state: Arc>, text_progress_slot: CodeIndexBuildProgressSlotV1, text_progress_owner_epoch: u64, + /// Existing durable daemon-authority epoch. It orders progress across + /// process restarts; the slot epoch orders only within this incarnation. + text_progress_producer_incarnation: u64, /// The durable text-artifact store for this generation's store root. text_artifact_store: DaemonCodeTextArtifactStoreV1, /// Exact scheduler authorities for shutdown and superseding source epochs. @@ -2526,6 +2529,7 @@ impl LatestCompleteCodeIndexV1 { state.rates_and_eta(total_lexical_bytes); let snapshot = CodeIndexBuildProgressV1 { generation_id: self.generation.manifest().generation_id.as_str().to_owned(), + producer_incarnation: self.text_progress_producer_incarnation, progress_epoch: 0, sealed_source_digest: build.sealed_identity.digest.as_str().to_owned(), phase, @@ -2570,6 +2574,7 @@ impl LatestCompleteCodeIndexV1 { .elapsed_micros(); Ok(CodeIndexBuildProgressV1 { generation_id: generation_id.as_str().to_owned(), + producer_incarnation: self.text_progress_producer_incarnation, progress_epoch: 0, sealed_source_digest: sealed_identity.digest.as_str().to_owned(), phase: CodeIndexBuildPhaseV1::Ready, @@ -3356,6 +3361,8 @@ pub(super) struct CodeIndexWorktreeSchedulerV1 { /// Immutable generation-scoped build snapshot. The registry clones this /// slot at mount so dashboard reads never acquire the scheduler mutex. build_progress: CodeIndexBuildProgressSlotV1, + /// Durable daemon-authority epoch bound by the process registry at mount. + progress_producer_incarnation: u64, /// Optional semantic hook: schedule `FastEmbed` projection without joining it. semantic_schedule: Option, @@ -3460,6 +3467,7 @@ impl CodeIndexWorktreeSchedulerV1 { ignored_source_admissions: Vec::new(), query_owners: Mutex::new(None), build_progress: Arc::new(RwLock::new(CodeIndexBuildProgressSlotStateV1::default())), + progress_producer_incarnation: 1, semantic_schedule: None, }; Ok(scheduler) @@ -3476,6 +3484,10 @@ impl CodeIndexWorktreeSchedulerV1 { self.resident_memory = resident_memory; } + pub(super) fn bind_progress_producer_incarnation(&mut self, producer_incarnation: u64) { + self.progress_producer_incarnation = producer_incarnation.max(1); + } + pub(super) fn build_progress_slot(&self) -> CodeIndexBuildProgressSlotV1 { Arc::clone(&self.build_progress) } @@ -4336,6 +4348,7 @@ impl CodeIndexWorktreeSchedulerV1 { text_progress_state, text_progress_slot: Arc::clone(&self.build_progress), text_progress_owner_epoch, + text_progress_producer_incarnation: self.progress_producer_incarnation, text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( &self.store_root, &self.publication, @@ -4407,6 +4420,7 @@ impl CodeIndexWorktreeSchedulerV1 { )), text_progress_slot: Arc::new(RwLock::new(progress_slot)), text_progress_owner_epoch, + text_progress_producer_incarnation: self.progress_producer_incarnation, text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( &self.store_root, &self.publication, diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index ae04c2512f..c8c234bae6 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -694,6 +694,9 @@ impl Drop for PendingWakeClaimV1 { #[derive(Clone)] pub(crate) struct CodeIndexSchedulerRegistryV1 { pub(super) max_worktrees: usize, + /// Durable daemon-authority epoch shared by every progress producer in + /// this registry. This is never derived from wall-clock time. + pub(super) progress_producer_incarnation: u64, pub(super) resident_memory: Arc, pub(super) byte_pool: Arc, pub(super) mounted: Arc>>, @@ -1933,6 +1936,7 @@ impl CodeIndexSchedulerRegistryV1 { ) .map(|mut scheduler| { scheduler.bind_resident_memory(Arc::clone(&self.resident_memory)); + scheduler.bind_progress_producer_incarnation(self.progress_producer_incarnation); scheduler }) } @@ -2164,6 +2168,7 @@ impl CodeIndexSchedulerRegistryV1 { let open_byte_pool = Arc::clone(&self.byte_pool); let open_semantic_schedule = semantic_schedule.clone(); let open_resident_memory = Arc::clone(&self.resident_memory); + let progress_producer_incarnation = self.progress_producer_incarnation; let (opened, cold_mount_reservation) = tokio::task::spawn_blocking(move || { #[cfg(test)] Self::pause_cold_mount_open_for_test(&open_project_root); @@ -2178,6 +2183,7 @@ impl CodeIndexSchedulerRegistryV1 { let mut opened = opened?; opened.replace_semantic_schedule_hook(open_semantic_schedule); opened.bind_resident_memory(open_resident_memory); + opened.bind_progress_producer_incarnation(progress_producer_incarnation); Ok::<_, CodeIndexSchedulerErrorV1>((opened, cold_mount_reservation)) }) .await diff --git a/src/daemon/code_index_scheduler/registry/resident_memory.rs b/src/daemon/code_index_scheduler/registry/resident_memory.rs index dae5d4377c..ef1310a178 100644 --- a/src/daemon/code_index_scheduler/registry/resident_memory.rs +++ b/src/daemon/code_index_scheduler/registry/resident_memory.rs @@ -11,22 +11,37 @@ use super::CodeIndexSchedulerRegistryV1; impl CodeIndexSchedulerRegistryV1 { #[cfg(test)] pub fn new(max_worktrees: usize) -> Self { - Self::with_resident_memory( + Self::with_resident_memory_and_progress_producer_incarnation( max_worktrees, Arc::new(ProcessResidentMemoryV1::new( DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, )), + 1, ) } + #[cfg(test)] pub fn with_resident_memory( max_worktrees: usize, resident_memory: Arc, + ) -> Self { + Self::with_resident_memory_and_progress_producer_incarnation( + max_worktrees, + resident_memory, + 1, + ) + } + + pub fn with_resident_memory_and_progress_producer_incarnation( + max_worktrees: usize, + resident_memory: Arc, + progress_producer_incarnation: u64, ) -> Self { let (generation_publications, _) = tokio::sync::broadcast::channel(super::GENERATION_PUBLICATION_CHANNEL_CAPACITY); Self { max_worktrees, + progress_producer_incarnation: progress_producer_incarnation.max(1), resident_memory, byte_pool: Arc::new(super::SharedCodeIndexBytePoolV1::default()), mounted: Arc::new(tokio::sync::Mutex::new(std::collections::BTreeMap::new())), diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index a35bc43f65..7d7edc456d 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -218,6 +218,7 @@ fn progress_snapshot_for_generation( ) -> crate::dashboard::code_index_freshness_api::CodeIndexBuildProgressV1 { crate::dashboard::code_index_freshness_api::CodeIndexBuildProgressV1 { generation_id: generation_id.as_str().to_owned(), + producer_incarnation: 1, progress_epoch: 0, sealed_source_digest: format!("sha256:{}", "a".repeat(64)), phase: crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::BulkCommit, diff --git a/src/daemon/engine.rs b/src/daemon/engine.rs index d7d23231c1..f1a392b605 100644 --- a/src/daemon/engine.rs +++ b/src/daemon/engine.rs @@ -136,6 +136,12 @@ pub(super) fn ensure_context_scout_owner_before_advertising( #[cfg(unix)] impl DaemonEngine { + pub(super) fn with_progress_producer_incarnation(mut self, producer_incarnation: u64) -> Self { + self.invocation = + DaemonInvocationState::with_progress_producer_incarnation(producer_incarnation); + self + } + pub(super) fn with_profile_identity( mut self, profile_identity: profile_identity::LocalProfileIdentityAuthorityV1, diff --git a/src/daemon/invocation_state.rs b/src/daemon/invocation_state.rs index f6fc9f2070..6cccdc301e 100644 --- a/src/daemon/invocation_state.rs +++ b/src/daemon/invocation_state.rs @@ -41,14 +41,22 @@ pub(crate) struct DaemonInvocationState { impl Default for DaemonInvocationState { fn default() -> Self { + Self::with_progress_producer_incarnation(1) + } +} + +impl DaemonInvocationState { + /// Construct one daemon-generation invocation state whose dashboard + /// progress is ordered by the existing durable daemon-authority epoch. + pub(super) fn with_progress_producer_incarnation(producer_incarnation: u64) -> Self { let resident_memory = Arc::new(ProcessResidentMemoryV1::new( DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, )); - let code_index_schedulers = - code_index_scheduler::CodeIndexSchedulerRegistryV1::with_resident_memory( - MAX_CACHED_PROJECT_SERVERS, - Arc::clone(&resident_memory), - ); + let code_index_schedulers = code_index_scheduler::CodeIndexSchedulerRegistryV1::with_resident_memory_and_progress_producer_incarnation( + MAX_CACHED_PROJECT_SERVERS, + Arc::clone(&resident_memory), + producer_incarnation, + ); let service = DaemonInvocationService::with_code_index_schedulers(code_index_schedulers.clone()); let query_authority_provider = @@ -71,9 +79,7 @@ impl Default for DaemonInvocationState { tracedecay_usecases::semantic_runtime::DaemonGlobalSemanticProjectionSchedulerV1::default(), } } -} -impl DaemonInvocationState { pub(super) fn invocation_service(&self) -> DaemonInvocationService { self.service.clone() } From bfd750a3c0068d8432ca68ecc3a914cda3ec1c4f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 02:01:12 +0000 Subject: [PATCH 12/69] fix(dashboard): order remounted index progress --- .../code/IndexFreshness.dom.test.tsx | 58 +++++++++++++++++- .../src/workspaces/code/IndexFreshness.tsx | 3 + .../observatory/ObservatoryPage.dom.test.tsx | 60 ++++++++++++++++++- .../observatory/ObservatoryPage.tsx | 3 + 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx index 924628c11a..79cdd0e827 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx @@ -185,7 +185,8 @@ describe('Code index freshness', () => { generation_id: 'generation.catchup.02', // The daemon restarted before this generation began, so its // in-memory progress epoch is lower than the rendered generation. - producer_incarnation: 2, + daemon_incarnation: 2, + producer_incarnation: 1, progress_epoch: 0, last_progress_micros: NOW_MICROS - 1, completed_files: 1, @@ -238,7 +239,8 @@ describe('Code index freshness', () => { staleness_state: 'indexing', progress: { ...progress(), - producer_incarnation: 2, + daemon_incarnation: 2, + producer_incarnation: 1, progress_epoch: 0, last_progress_micros: NOW_MICROS - 1, completed_files: 1, @@ -265,6 +267,57 @@ describe('Code index freshness', () => { expect(screen.queryByText('250 / 500 files')).toBeNull(); }); + it('accepts a same-daemon remounted producer and rejects its retired predecessor', async () => { + vi.useFakeTimers(); + const retired = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { ...progress(), progress_epoch: 100 }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const remounted = envelope('loading', { + worktrees: [ + { + ...worktree(), + latest_generation_id: null, + snapshot_content_identity: null, + sealed_at_micros: null, + staleness_state: 'indexing', + progress: { + ...progress(), + producer_incarnation: 2, + progress_epoch: 2, + completed_files: 1, + }, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(retired), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(remounted), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(retired), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('250 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('1 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('250 / 500 files')).toBeNull(); + }); + it('polls an active build each second and returns to the ready cadence', async () => { vi.useFakeTimers(); const active = envelope('loading', { @@ -380,6 +433,7 @@ function worktree() { function progress() { return { generation_id: 'generation.catchup.01', + daemon_incarnation: 1, producer_incarnation: 1, progress_epoch: 1, sealed_source_digest: 'sha256:sealed-source-catchup', diff --git a/dashboard/src/workspaces/code/IndexFreshness.tsx b/dashboard/src/workspaces/code/IndexFreshness.tsx index b100af8d3f..0cd09a50d8 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.tsx @@ -277,6 +277,9 @@ function isCurrentOrNewerProgress( incoming: CodeIndexBuildProgress, rendered: CodeIndexBuildProgress, ): boolean { + if (incoming.daemon_incarnation !== rendered.daemon_incarnation) { + return incoming.daemon_incarnation > rendered.daemon_incarnation; + } if (incoming.producer_incarnation !== rendered.producer_incarnation) { return incoming.producer_incarnation > rendered.producer_incarnation; } diff --git a/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx b/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx index 7e7e70a91c..4dd5a09c0d 100644 --- a/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx +++ b/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx @@ -95,7 +95,8 @@ describe('ObservatoryPage store telemetry', () => { progress: { ...firstProgress, generation_id: 'generation.catchup.02', - producer_incarnation: 2, + daemon_incarnation: 2, + producer_incarnation: 1, progress_epoch: 0, last_progress_micros: SAMPLE_CURRENT_MICROS - 1, completed_files: 1, @@ -137,7 +138,8 @@ describe('ObservatoryPage store telemetry', () => { ...beforeRestartWorktree, progress: { ...beforeRestartProgress, - producer_incarnation: 2, + daemon_incarnation: 2, + producer_incarnation: 1, progress_epoch: 0, last_progress_micros: SAMPLE_CURRENT_MICROS - 1, completed_files: 1, @@ -177,6 +179,59 @@ describe('ObservatoryPage store telemetry', () => { expect(screen.queryByText('250 / 500 files')).toBeNull(); }); + it('accepts a same-daemon remounted producer and rejects its retired predecessor', async () => { + vi.useFakeTimers(); + const retired = codeIndexFreshnessEnvelope(); + const retiredWorktree = retired.payload.worktrees[0]!; + const retiredProgress = retiredWorktree.progress; + const retiredWithEpoch = { + ...retired, + payload: { + ...retired.payload, + worktrees: [ + { + ...retiredWorktree, + progress: { ...retiredProgress, progress_epoch: 100 }, + }, + ], + }, + }; + const remounted = { + ...retired, + payload: { + ...retired.payload, + worktrees: [ + { + ...retiredWorktree, + progress: { + ...retiredProgress, + producer_incarnation: 2, + progress_epoch: 2, + completed_files: 1, + }, + }, + ], + }, + }; + const progressResponses = [retiredWithEpoch, remounted, retiredWithEpoch]; + let progressResponse = 0; + stubTelemetry( + telemetryPayload(), + emptyStorageFindingsPayload(), + () => progressResponses[Math.min(progressResponse++, progressResponses.length - 1)]!, + ); + renderObservatory(); + + await advanceTimers(0); + expect(screen.getByText('250 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('1 / 500 files')).toBeTruthy(); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.queryByText('250 / 500 files')).toBeNull(); + }); + it('removes an unmounted worktree before switching to the next scope pipeline', async () => { vi.useFakeTimers(); const first = codeIndexFreshnessEnvelope(); @@ -563,6 +618,7 @@ function codeIndexFreshnessEnvelope() { staleness_state: 'indexing', progress: { generation_id: 'generation.catchup.01', + daemon_incarnation: 1, producer_incarnation: 1, progress_epoch: 1, sealed_source_digest: 'sha256:sealed-source-catchup', diff --git a/dashboard/src/workspaces/observatory/ObservatoryPage.tsx b/dashboard/src/workspaces/observatory/ObservatoryPage.tsx index 2ae2a1eccd..deda3920c7 100644 --- a/dashboard/src/workspaces/observatory/ObservatoryPage.tsx +++ b/dashboard/src/workspaces/observatory/ObservatoryPage.tsx @@ -371,6 +371,9 @@ function isCurrentOrNewerCodeIndexProgress( incoming: CodeIndexBuildProgressV1, rendered: CodeIndexBuildProgressV1, ): boolean { + if (incoming.daemon_incarnation !== rendered.daemon_incarnation) { + return incoming.daemon_incarnation > rendered.daemon_incarnation; + } if (incoming.producer_incarnation !== rendered.producer_incarnation) { return incoming.producer_incarnation > rendered.producer_incarnation; } From dc714899c58b3a1f37a0eb4752f9cd2a1af21081 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 02:01:33 +0000 Subject: [PATCH 13/69] perf(index): admit durable lexical page prefixes --- .../src/production/lexical_page_source.rs | 154 ++++++++++++++---- 1 file changed, 124 insertions(+), 30 deletions(-) diff --git a/crates/tracedecay-code-index/src/production/lexical_page_source.rs b/crates/tracedecay-code-index/src/production/lexical_page_source.rs index 7ce62459fa..edf227177d 100644 --- a/crates/tracedecay-code-index/src/production/lexical_page_source.rs +++ b/crates/tracedecay-code-index/src/production/lexical_page_source.rs @@ -1,4 +1,7 @@ -use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +use std::{ + io::{BufRead, BufReader, Read, Seek, SeekFrom}, + num::NonZeroUsize, +}; use sha2::{Digest, Sha256}; use tracedecay_domain::ExactTechnicalTermV1; @@ -701,7 +704,7 @@ impl VerifiedSealedLexicalPageBatchBoundsV1 { } } -/// Result of one all-or-nothing lexical-page batch admission attempt. +/// Result of one accepted lexical-page batch prefix. #[derive(Debug)] #[allow(clippy::large_enum_variant)] // staged pages dominate this terminal read result pub enum VerifiedSealedLexicalPageBatchReadV1 { @@ -731,10 +734,7 @@ enum StagedSealedLexicalPageReadV1 { #[allow(clippy::large_enum_variant)] // staged pages dominate this private batch result enum StagedSealedLexicalPageBatchReadV1 { - Pages { - pages: Vec, - cursor: VerifiedSealedLexicalCursorV1, - }, + Pages(Vec), Complete(VerifiedSealedLexicalSourceReceiptV1), } @@ -1061,15 +1061,15 @@ impl VerifiedSealedLexicalPageSourceV1 { } } - /// Stage a bounded ordered page batch and advance only after the caller - /// accepts the complete borrowed slice. The source cursor remains at its - /// pre-batch position on source or callback failure, so retrying emits the - /// same ordered page sequence. + /// Stage a bounded ordered page batch and advance only through the prefix + /// the caller durably accepts. The source cursor remains at its pre-batch + /// position on source, callback, or accepted-prefix validation failure, so + /// retrying emits the same ordered page sequence. pub fn next_page_batch_if( &mut self, control: &dyn CodeIndexExecutionControlV1, bounds: VerifiedSealedLexicalPageBatchBoundsV1, - admit: impl FnOnce(&[VerifiedSealedLexicalPageV1]) -> Result<(), E>, + admit: impl FnOnce(&[VerifiedSealedLexicalPageV1]) -> Result, ) -> Result, CodeIndexProductionErrorV1> { let staged = hotpath::measure_block!("code_index.lexical_source.batch_stage", { (|| { @@ -1137,10 +1137,7 @@ impl VerifiedSealedLexicalPageSourceV1 { Ok(if let Some(receipt) = completion { StagedSealedLexicalPageBatchReadV1::Complete(receipt) } else { - StagedSealedLexicalPageBatchReadV1::Pages { - pages, - cursor: working_cursor, - } + StagedSealedLexicalPageBatchReadV1::Pages(pages) }) })() })?; @@ -1149,15 +1146,22 @@ impl VerifiedSealedLexicalPageSourceV1 { StagedSealedLexicalPageBatchReadV1::Complete(receipt) => { Ok(Ok(VerifiedSealedLexicalPageBatchReadV1::Complete(receipt))) } - StagedSealedLexicalPageBatchReadV1::Pages { - pages, - cursor: working_cursor, - } => { - if let Err(error) = admit(&pages) { - return Ok(Err(error)); + StagedSealedLexicalPageBatchReadV1::Pages(mut pages) => { + let accepted_prefix = match admit(&pages) { + Ok(accepted_prefix) => accepted_prefix, + Err(error) => return Ok(Err(error)), + }; + let accepted_page_count = accepted_prefix.get(); + if accepted_page_count > pages.len() { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page batch accepted prefix exceeds staged page count" + .to_owned(), + )); } - crate::hotpath_observe::record_pages(working_cursor.next_page_ordinal()); - self.cursor = working_cursor; + let accepted_cursor = pages[accepted_page_count - 1].next_cursor().clone(); + pages.truncate(accepted_page_count); + crate::hotpath_observe::record_pages(accepted_cursor.next_page_ordinal()); + self.cursor = accepted_cursor; Ok(Ok(VerifiedSealedLexicalPageBatchReadV1::Pages(pages))) } } @@ -2306,7 +2310,11 @@ mod lexical_page_source_tests { } fn fixture() -> SealedSourceFixture { - let source = BATCH_FIXTURE_SOURCE.as_bytes(); + fixture_for_source(BATCH_FIXTURE_SOURCE) + } + + fn fixture_for_source(source: &str) -> SealedSourceFixture { + let source = source.as_bytes(); let file = SanitizedCodeFileV1 { file_occurrence_id: FileOccurrenceId::new("file.lexical-page-batch") .expect("fixture file occurrence ID"), @@ -2497,7 +2505,7 @@ mod lexical_page_source_tests { let rejected = source .next_page_batch_if(&ActiveControl, bounds_for(&expected[..2]), |pages| { assert_eq!(pages.len(), 2, "fixture stages a full two-page batch"); - Err::<(), _>("builder rejects the complete batch") + Err::("builder rejects the complete batch") }) .expect("source stages the rejected batch"); assert_eq!( @@ -2519,6 +2527,44 @@ mod lexical_page_source_tests { assert_page_matches(&retried, &expected[0]); } + #[test] + fn out_of_range_accepted_prefix_keeps_the_exact_cursor_and_retries_the_first_page() { + let fixture = fixture(); + let expected = one_page_expectations(&fixture); + assert!( + expected.len() >= 2, + "fixture must provide a multi-page source" + ); + let mut source = fixture.open(); + let cursor_before = source + .cursor() + .persisted_bytes() + .expect("initial cursor persists"); + let error = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected[..2]), |pages| { + assert_eq!(pages.len(), 2, "fixture stages a full two-page batch"); + Ok::<_, ()>( + NonZeroUsize::new(pages.len() + 1) + .expect("out-of-range accepted prefix remains non-zero"), + ) + }) + .expect_err("out-of-range accepted prefix must be refused"); + assert!(matches!(error, CodeIndexProductionErrorV1::Contract(_))); + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("rejected cursor persists"), + cursor_before, + ); + + let retried = match source.next_page(&ActiveControl).expect("one-page retry") { + VerifiedSealedLexicalPageReadV1::Page(page) => page, + VerifiedSealedLexicalPageReadV1::Complete(_) => panic!("fixture must retain pages"), + }; + assert_page_matches(&retried, &expected[0]); + } + #[test] fn count_bound_returns_the_first_two_one_page_values_in_order() { let fixture = fixture(); @@ -2531,7 +2577,7 @@ mod lexical_page_source_tests { let batch = source .next_page_batch_if(&ActiveControl, bounds_for(&expected[..2]), |pages| { assert_eq!(pages.len(), 2); - Ok::<(), ()>(()) + Ok::<_, ()>(NonZeroUsize::new(pages.len()).expect("staged batch is non-empty")) }) .expect("source stages a count-bounded batch") .expect("callback accepts the count-bounded batch"); @@ -2548,6 +2594,54 @@ mod lexical_page_source_tests { ); } + #[test] + fn accepts_only_fifteen_of_sixteen_staged_parser_backed_pages() { + let source_text = (0..16) + .map(|index| format!("pub fn batch_prefix_page_{index}() -> usize {{ {index} }}\n")) + .collect::(); + let fixture = fixture_for_source(&source_text); + let expected = one_page_expectations(&fixture); + assert!( + expected.len() >= 16, + "parser-backed fixture must expose sixteen one-page values" + ); + let mut source = fixture.open(); + let accepted = source + .next_page_batch_if(&ActiveControl, bounds_for(&expected[..16]), |pages| { + assert_eq!( + pages.len(), + 16, + "fixture stages sixteen parser-backed pages" + ); + Ok::<_, ()>(NonZeroUsize::new(15).expect("fifteen is non-zero")) + }) + .expect("source stages the parser-backed batch") + .expect("callback accepts a fifteen-page prefix"); + let accepted = pages(accepted); + assert_eq!(accepted.len(), 15); + for (page, expected) in accepted.iter().zip(&expected[..15]) { + assert_page_matches(page, expected); + } + assert_eq!( + source + .cursor() + .persisted_bytes() + .expect("accepted-prefix cursor persists"), + expected[14].next_cursor, + ); + + let next = match source + .next_page(&ActiveControl) + .expect("read the first unaccepted page") + { + VerifiedSealedLexicalPageReadV1::Page(page) => page, + VerifiedSealedLexicalPageReadV1::Complete(_) => { + panic!("the sixteenth staged page must remain available") + } + }; + assert_page_matches(&next, &expected[15]); + } + #[test] fn retained_byte_bound_stops_before_the_next_larger_one_page_value() { let fixture = fixture(); @@ -2579,7 +2673,7 @@ mod lexical_page_source_tests { let batch = source .next_page_batch_if(&ActiveControl, bounds, |pages| { assert_eq!(pages.len(), 1, "larger next page must stay unstaged"); - Ok::<(), ()>(()) + Ok::<_, ()>(NonZeroUsize::new(pages.len()).expect("staged batch is non-empty")) }) .expect("source stages the retained-byte-bounded batch") .expect("callback accepts the retained-byte-bounded batch"); @@ -2613,7 +2707,7 @@ mod lexical_page_source_tests { let accepted = source .next_page_batch_if(&ActiveControl, bounds_for(&expected), |pages| { assert_eq!(pages.len(), expected.len()); - Ok::<(), ()>(()) + Ok::<_, ()>(NonZeroUsize::new(pages.len()).expect("staged batch is non-empty")) }) .expect("source stages the final batch") .expect("callback accepts the final batch"); @@ -2627,7 +2721,7 @@ mod lexical_page_source_tests { let complete = source .next_page_batch_if(&ActiveControl, bounds_for(&expected), |_| { callback_called = true; - Ok::<(), ()>(()) + Ok::<_, ()>(NonZeroUsize::MIN) }) .expect("completed source stays readable") .expect("completion has no callback error"); @@ -2661,7 +2755,7 @@ mod lexical_page_source_tests { let error = source .next_page_batch_if(&control, bounds_for(&expected[..2]), |_| { callback_called = true; - Ok::<(), ()>(()) + Ok::<_, ()>(NonZeroUsize::MIN) }) .expect_err("cancellation must interrupt batch staging"); assert!(matches!( From f9c8700a7d686446a7ea58a6c3ad675c0b912dbb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 02:06:34 +0000 Subject: [PATCH 14/69] perf(index): borrow admitted sealed decode bytes --- .../src/production/sealed_codec.rs | 299 ++++++++++++++---- 1 file changed, 231 insertions(+), 68 deletions(-) diff --git a/crates/tracedecay-code-index/src/production/sealed_codec.rs b/crates/tracedecay-code-index/src/production/sealed_codec.rs index 4aa3e13329..05f9cfa7af 100644 --- a/crates/tracedecay-code-index/src/production/sealed_codec.rs +++ b/crates/tracedecay-code-index/src/production/sealed_codec.rs @@ -138,9 +138,10 @@ struct SealedPublishedGenerationEnvelopeV1 { } #[derive(Deserialize)] -struct SealedPublishedGenerationRawEnvelopeV1 { +struct SealedPublishedGenerationRawEnvelopeV1<'a> { state_digest: ManifestDigest, - generation: Box, + #[serde(borrow)] + generation: &'a RawValue, } #[derive(Deserialize)] @@ -185,6 +186,22 @@ fn read_admitted_bytes( Ok(bytes) } +fn admit_sealed_generation_bytes( + bytes: &[u8], + admitted_len: u64, +) -> Result<&[u8], CodeIndexProductionErrorV1> { + admit_sealed_generation_len(admitted_len)?; + let actual_len = u64::try_from(bytes.len()).map_err(|_| { + CodeIndexProductionErrorV1::Contract("sealed generation length exceeds u64".to_owned()) + })?; + if actual_len != admitted_len { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed generation length does not match its admitted length".to_owned(), + )); + } + Ok(bytes) +} + const SEALED_GENERATION_WRITE_CHUNK_BYTES_V1: usize = 64 * 1024; struct BoundedChunkWriterV1<'a, W> { @@ -472,52 +489,74 @@ impl CodeIndexPublishedGenerationV1 { /// Restore and revalidate a complete sealed generation. #[hotpath::measure] pub fn decode_sealed(bytes: &[u8]) -> Result { - crate::hotpath_observe::record_seal_bytes(bytes.len() as u64); let admitted_len = u64::try_from(bytes.len()).map_err(|_| { CodeIndexProductionErrorV1::Contract("sealed generation length exceeds u64".to_owned()) })?; - Self::decode_sealed_reader(std::io::Cursor::new(bytes), admitted_len) + Self::decode_admitted_sealed_bytes(bytes, admitted_len) } pub fn decode_sealed_reader( reader: R, admitted_len: u64, ) -> Result { - let bytes = read_admitted_bytes(reader, admitted_len)?; - let probe: SealedPublishedGenerationFormatProbeV1 = serde_json::from_slice(&bytes) - .map_err(|error| { + let bytes = hotpath::measure_block!( + "code_index.sealed_decode.admitted_read", + read_admitted_bytes(reader, admitted_len) + )?; + Self::decode_admitted_sealed_bytes(&bytes, admitted_len) + } + + fn decode_admitted_sealed_bytes( + bytes: &[u8], + admitted_len: u64, + ) -> Result { + let bytes = hotpath::measure_block!( + "code_index.sealed_decode.input_admission", + admit_sealed_generation_bytes(bytes, admitted_len) + )?; + crate::hotpath_observe::record_seal_bytes(admitted_len); + let probe: SealedPublishedGenerationFormatProbeV1 = hotpath::measure_block!( + "code_index.sealed_decode.envelope_parse", + serde_json::from_slice(bytes).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "sealed generation format probe failed: {error}" )) - })?; + }) + )?; let envelope = match probe.generation.format_revision { - LEGACY_CANONICAL_SEALED_GENERATION_FORMAT_REVISION => serde_json::from_slice::< - SealedPublishedGenerationEnvelopeV1, - >(&bytes) - .map_err(|error| { - CodeIndexProductionErrorV1::Contract(format!( - "sealed generation decoding failed: {error}" - )) - })?, + LEGACY_CANONICAL_SEALED_GENERATION_FORMAT_REVISION => hotpath::measure_block!( + "code_index.sealed_decode.persisted_materialization", + serde_json::from_slice::(bytes).map_err( + |error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation decoding failed: {error}" + )) + } + ) + )?, SEALED_GENERATION_FORMAT_REVISION_V1 => { - let raw: SealedPublishedGenerationRawEnvelopeV1 = serde_json::from_slice(&bytes) - .map_err(|error| { + let raw: SealedPublishedGenerationRawEnvelopeV1 = hotpath::measure_block!( + "code_index.sealed_decode.envelope_parse", + serde_json::from_slice(bytes).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "sealed generation decoding failed: {error}" )) - })?; + }) + )?; let expected_digest = json_generation_digest(raw.generation.get().as_bytes())?; if expected_digest != raw.state_digest { return Err(CodeIndexProductionErrorV1::Contract( "sealed generation state digest does not match its payload".to_owned(), )); } - let generation: PersistedPublishedGenerationV1 = + let generation: PersistedPublishedGenerationV1 = hotpath::measure_block!( + "code_index.sealed_decode.persisted_materialization", serde_json::from_str(raw.generation.get()).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "sealed generation payload decoding failed: {error}" )) - })?; + }) + )?; if generation.format_revision.0 != SEALED_GENERATION_FORMAT_REVISION_V1 { return Err(CodeIndexProductionErrorV1::Contract( "sealed generation format revision is incompatible".to_owned(), @@ -552,62 +591,93 @@ impl CodeIndexPublishedGenerationV1 { )); } - let repository_parse_identity = envelope.generation.repository_parse_identity; - let ignored_source_roster = IgnoredSourceRosterV1::restore( - &envelope.generation.snapshot, - &repository_parse_identity, - envelope.generation.ignored_source_admissions, - envelope.generation.ignored_source_admissions_digest, - )?; - - let mut files = Vec::with_capacity(envelope.generation.files.len()); - for file in envelope.generation.files { - let exact_authority = ExactExtractionAuthorityV1::restore(&file.artifacts.chunks) - .map_err(CodeIndexProductionErrorV1::Chunk)?; - files.push(FileGenerationArtifactsV1 { - authority: file.authority, - extraction: file.extraction, - artifacts: file.artifacts, - exact_authority, - }); - } - let chunks = GenerationChunkManifestV1::new( - envelope.generation.manifest.generation_id.clone(), - files - .iter() - .map(|file| file.artifacts.chunks.clone()) - .collect(), - ) - .map_err(CodeIndexProductionErrorV1::Increment)?; - let symbols = GenerationSymbolIndexV1::new( - envelope.generation.manifest.generation_id.clone(), - files - .iter() - .flat_map(|file| file.artifacts.symbols.clone()) - .collect(), - ) - .map_err(CodeIndexProductionErrorV1::Lineage)?; - let imports = derive_import_evidence(&files); - let (edges, edge_abstentions) = collect_edge_evidence(&files); - let projection = ProjectionPublicationHandoffV1::restore( - envelope.generation.projection_request, - envelope.generation.projection_receipt, - ) - .map_err(CodeIndexProductionErrorV1::Projection)?; + let PersistedPublishedGenerationV1 { + format_revision: _, + manifest, + snapshot, + repository_parse_identity, + ignored_source_admissions, + ignored_source_admissions_digest, + files: persisted_files, + lineage, + coverage, + capability, + projection_request, + projection_receipt, + } = envelope.generation; + let ( + ignored_source_roster, + files, + chunks, + symbols, + imports, + edges, + edge_abstentions, + projection, + ) = hotpath::measure_block!("code_index.sealed_decode.authority_restore", { + let ignored_source_roster = IgnoredSourceRosterV1::restore( + &snapshot, + &repository_parse_identity, + ignored_source_admissions, + ignored_source_admissions_digest, + )?; + let mut files = Vec::with_capacity(persisted_files.len()); + for file in persisted_files { + let exact_authority = ExactExtractionAuthorityV1::restore(&file.artifacts.chunks) + .map_err(CodeIndexProductionErrorV1::Chunk)?; + files.push(FileGenerationArtifactsV1 { + authority: file.authority, + extraction: file.extraction, + artifacts: file.artifacts, + exact_authority, + }); + } + let chunks = GenerationChunkManifestV1::new( + manifest.generation_id.clone(), + files + .iter() + .map(|file| file.artifacts.chunks.clone()) + .collect(), + ) + .map_err(CodeIndexProductionErrorV1::Increment)?; + let symbols = GenerationSymbolIndexV1::new( + manifest.generation_id.clone(), + files + .iter() + .flat_map(|file| file.artifacts.symbols.clone()) + .collect(), + ) + .map_err(CodeIndexProductionErrorV1::Lineage)?; + let imports = derive_import_evidence(&files); + let (edges, edge_abstentions) = collect_edge_evidence(&files); + let projection = + ProjectionPublicationHandoffV1::restore(projection_request, projection_receipt) + .map_err(CodeIndexProductionErrorV1::Projection)?; + Ok::<_, CodeIndexProductionErrorV1>(( + ignored_source_roster, + files, + chunks, + symbols, + imports, + edges, + edge_abstentions, + projection, + )) + })?; let generation = Self { - manifest: envelope.generation.manifest, - snapshot: envelope.generation.snapshot, + manifest, + snapshot, repository_parse_identity, ignored_source_roster, files, chunks, symbols, - lineage: envelope.generation.lineage, + lineage, imports, edges, edge_abstentions, - coverage: envelope.generation.coverage, - capability: envelope.generation.capability, + coverage, + capability, projection, validated: OnceLock::new(), admitted: OnceLock::new(), @@ -634,8 +704,48 @@ impl CodeIndexPublishedGenerationV1 { #[cfg(test)] mod tests { + use std::alloc::{GlobalAlloc, Layout, System}; + use std::cell::Cell; + use super::*; + struct LargestAllocationRecorderV1; + + thread_local! { + static LARGEST_ALLOCATION_BYTES: Cell = const { Cell::new(0) }; + } + + unsafe impl GlobalAlloc for LargestAllocationRecorderV1 { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + LARGEST_ALLOCATION_BYTES.with(|largest| largest.set(largest.get().max(layout.size()))); + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + LARGEST_ALLOCATION_BYTES.with(|largest| largest.set(largest.get().max(layout.size()))); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + LARGEST_ALLOCATION_BYTES.with(|largest| largest.set(largest.get().max(new_size))); + unsafe { System.realloc(ptr, layout, new_size) } + } + } + + #[global_allocator] + static TEST_ALLOCATOR: LargestAllocationRecorderV1 = LargestAllocationRecorderV1; + + fn measure_largest_allocation(work: impl FnOnce() -> T) -> (T, usize) { + LARGEST_ALLOCATION_BYTES.with(|largest| largest.set(0)); + let value = work(); + let largest = LARGEST_ALLOCATION_BYTES.with(Cell::get); + (value, largest) + } + struct MaximumWriteSink { inner: std::io::Cursor>, maximum_write: usize, @@ -766,4 +876,57 @@ mod tests { if message.contains("admitted length") )); } + + #[test] + fn borrowed_decode_does_not_allocate_a_second_corpus_sized_buffer() { + const PADDING_BYTES: usize = 8 * 1024 * 1024; + let wrong_digest = ManifestDigest::from_sha256_bytes(&[0; 32]).expect("fixture digest"); + let mut sealed = format!( + "{{\"state_digest\":{},\"generation\":{{\"format_revision\":{},\"padding\":\"", + serde_json::to_string(&wrong_digest).expect("fixture digest serialization"), + SEALED_GENERATION_FORMAT_REVISION_V1, + ) + .into_bytes(); + sealed.resize(sealed.len() + PADDING_BYTES, b'x'); + sealed.extend_from_slice(b"\"}}"); + + let (result, largest_allocation) = + measure_largest_allocation(|| CodeIndexPublishedGenerationV1::decode_sealed(&sealed)); + + assert!(matches!( + result, + Err(CodeIndexProductionErrorV1::Contract(message)) + if message.contains("state digest does not match") + )); + assert!( + largest_allocation < sealed.len() / 2, + "borrowed decode allocated {largest_allocation} bytes for a {} byte sealed input", + sealed.len() + ); + } + + #[test] + fn raw_v6_payload_borrows_the_callers_admitted_bytes() { + const PADDING_BYTES: usize = 4 * 1024 * 1024; + let digest = ManifestDigest::from_sha256_bytes(&[0; 32]).expect("fixture digest"); + let mut sealed = format!( + "{{\"state_digest\":{},\"generation\":{{\"format_revision\":{},\"padding\":\"", + serde_json::to_string(&digest).expect("fixture digest serialization"), + SEALED_GENERATION_FORMAT_REVISION_V1, + ) + .into_bytes(); + sealed.resize(sealed.len() + PADDING_BYTES, b'x'); + sealed.extend_from_slice(b"\"}}"); + + let raw: SealedPublishedGenerationRawEnvelopeV1 = + serde_json::from_slice(&sealed).expect("raw envelope parses"); + let payload_start = raw.generation.get().as_ptr() as usize; + let admitted_start = sealed.as_ptr() as usize; + let admitted_end = admitted_start + sealed.len(); + + assert!( + (admitted_start..admitted_end).contains(&payload_start), + "the raw payload must point into the caller's admitted byte slice" + ); + } } From ac6b60d042ea1d3618a3af38d73e537f5485bb8e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 02:24:57 +0000 Subject: [PATCH 15/69] perf(query): defer lexical serving indexes --- .../lexical/projection/artifact/builder.rs | 653 +++++++++++++----- .../lexical/projection/artifact/format.rs | 32 +- .../lexical/projection/artifact/prepared.rs | 67 +- .../candidate_producers.rs | 311 +++++++-- 4 files changed, 756 insertions(+), 307 deletions(-) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index 2413c99e4d..26e4bee9bc 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -1,3 +1,5 @@ +use std::cmp::Reverse; +use std::collections::BinaryHeap; use std::fs::File; use std::path::{Path, PathBuf}; @@ -22,7 +24,7 @@ use tracedecay_private_fs::{create_private_file_retained, open_private_file}; use super::format::{ CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1, CodeLexicalArtifactSectionDigestV1, RECEIPT_RESERVATION_BYTES, SECTION_NAMES, VerifiedCodeLexicalArtifactV1, artifact_digest, - decode_padded_receipt, decode_padded_receipt_with_control, metadata_digest, + decode_padded_receipt, decode_padded_receipt_with_control, encode_field, metadata_digest, new_verified_receipt, padded_receipt, verify_required_artifact_indexes, }; use super::postings::document_ngram_scratch; @@ -106,7 +108,7 @@ impl FinalizationSectionV1 { "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor FROM source_pages ORDER BY page_ordinal" } Self::DocumentIntegrity => { - "SELECT document_id, digest FROM document_integrity ORDER BY document_id" + "SELECT document_id, chunk_id, digest FROM document_integrity ORDER BY document_id" } Self::ImportIntegrity => { "SELECT canonical, digest FROM import_integrity ORDER BY canonical" @@ -142,10 +144,10 @@ impl FinalizationSectionV1 { "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor FROM source_pages WHERE page_ordinal > ?1 ORDER BY page_ordinal LIMIT ?2" } (Self::DocumentIntegrity, false) => { - "SELECT document_id, digest FROM document_integrity ORDER BY document_id LIMIT ?1" + "SELECT document_id, chunk_id, digest FROM document_integrity ORDER BY document_id LIMIT ?1" } (Self::DocumentIntegrity, true) => { - "SELECT document_id, digest FROM document_integrity WHERE document_id > ?1 ORDER BY document_id LIMIT ?2" + "SELECT document_id, chunk_id, digest FROM document_integrity WHERE document_id > ?1 ORDER BY document_id LIMIT ?2" } (Self::ImportIntegrity, false) => { "SELECT canonical, digest FROM import_integrity ORDER BY canonical LIMIT ?1" @@ -309,15 +311,16 @@ struct PersistedFinalizationStateV1 { #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum PersistedFinalizationPhaseV1 { - Build, - Verify, + Statistics, + Indexes, + Digest, } impl PersistedFinalizationPhaseV1 { const fn public(self) -> CodeLexicalArtifactFinalizationPhaseV1 { match self { - Self::Build => CodeLexicalArtifactFinalizationPhaseV1::IndexBuild, - Self::Verify => CodeLexicalArtifactFinalizationPhaseV1::Verification, + Self::Statistics | Self::Indexes => CodeLexicalArtifactFinalizationPhaseV1::IndexBuild, + Self::Digest => CodeLexicalArtifactFinalizationPhaseV1::Verification, } } } @@ -373,11 +376,15 @@ impl FinalizationWakeMetricsV1 { fn digest_pass(&self, pass: PersistedFinalizationPhaseV1) { #[cfg(feature = "hotpath")] match pass { - PersistedFinalizationPhaseV1::Build => { - hotpath::gauge!("query.artifact.finalization.digest_pass.build_total").inc(1u64); + PersistedFinalizationPhaseV1::Statistics => { + hotpath::gauge!("query.artifact.finalization.statistics_wakes_total").inc(1u64); } - PersistedFinalizationPhaseV1::Verify => { - hotpath::gauge!("query.artifact.finalization.digest_pass.verify_total").inc(1u64); + PersistedFinalizationPhaseV1::Indexes => { + hotpath::gauge!("query.artifact.finalization.index_wakes_total").inc(1u64); + } + PersistedFinalizationPhaseV1::Digest => { + hotpath::gauge!("query.artifact.finalization.digest_pass.authenticated_total") + .inc(1u64); } }; #[cfg(not(feature = "hotpath"))] @@ -571,8 +578,15 @@ impl CodeLexicalArtifactBuilderV1 { require_integrity(&connection, control)?; let expected_digest = metadata_digest(&expected_metadata)?; verify_artifact_state_metadata(&connection, &expected_metadata, &expected_digest, control)?; - verify_required_artifact_indexes(&connection)?; - read_receipt_with_control(&connection, control)?; + let receipt = read_receipt_with_control(&connection, control)?; + let finalization = load_finalization_state(&connection)?; + if receipt.is_some() + || finalization + .as_ref() + .is_some_and(|state| state.phase == PersistedFinalizationPhaseV1::Digest) + { + verify_required_artifact_indexes(&connection)?; + } validate_contiguous_pages(&connection, control)?; checkpoint(control)?; crate::hotpath_metrics::Residency::Rebuilding.record("query.artifact.residency"); @@ -630,6 +644,65 @@ impl CodeLexicalArtifactBuilderV1 { page_batch_ledger_charge_bytes(&self.metadata, pages) } + /// Return the largest contiguous input prefix whose complete retained, + /// prepared-output, and active-worker scratch claim fits this builder. + /// Zero is truthful when even the first page cannot be admitted; callers + /// retain the source cursor and can surface the existing typed bound. + pub fn largest_admissible_page_prefix( + &self, + pages: &[VerifiedSealedLexicalPageV1], + ) -> Result { + self.verify_path_binding()?; + let worker_limit = tracedecay_code_index::parallelism::indexing_workers(); + let mut retained = 0usize; + let mut prepared = 0usize; + let mut active_scratch = 0usize; + let mut largest_scratch = BinaryHeap::>::new(); + for (index, page) in pages.iter().enumerate() { + if page.retained_owned_bytes() > CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 { + return Err(CodeLexicalArtifactErrorV1::Contract(format!( + "sealed lexical page retained bytes exceed the {}-byte artifact input bound", + CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 + ))); + } + retained = retained + .checked_add(page.retained_owned_bytes()) + .ok_or_else(batch_ledger_overflow)?; + prepared = prepared + .checked_add(page_prepared_retained_upper_bound_bytes( + &self.metadata, + page, + )?) + .ok_or_else(batch_ledger_overflow)?; + let scratch = page_transient_peak_bytes(&self.metadata, page, usize::MAX)?; + if largest_scratch.len() < worker_limit { + largest_scratch.push(Reverse(scratch)); + active_scratch = active_scratch + .checked_add(scratch) + .ok_or_else(batch_ledger_overflow)?; + } else if let Some(Reverse(smallest)) = largest_scratch.peek().copied() + && scratch > smallest + { + largest_scratch.pop(); + largest_scratch.push(Reverse(scratch)); + active_scratch = active_scratch + .checked_sub(smallest) + .and_then(|bytes| bytes.checked_add(scratch)) + .ok_or_else(batch_ledger_overflow)?; + } + let required = self + .fixed_ledger_charge_bytes + .checked_add(retained) + .and_then(|bytes| bytes.checked_add(prepared)) + .and_then(|bytes| bytes.checked_add(active_scratch)) + .ok_or_else(batch_ledger_overflow)?; + if required > self.memory_budget_bytes { + return Ok(index); + } + } + Ok(pages.len()) + } + /// Append one page through the canonical atomic batch path. pub fn append_page( &mut self, @@ -808,16 +881,16 @@ impl CodeLexicalArtifactBuilderV1 { Ok::<(), CodeLexicalArtifactErrorV1>(()) })?; record_batch_import_metrics(pages); - hotpath::measure_block!( - "query.artifact.batch.postings", - append_prepared_postings(&transaction, pages, control) - )?; - record_batch_posting_metrics(pages); hotpath::measure_block!( "query.artifact.batch.rows", append_prepared_rows(&transaction, pages, control) )?; record_batch_row_metrics(pages); + hotpath::measure_block!( + "query.artifact.batch.postings", + append_prepared_postings(&transaction, pages, control) + )?; + record_batch_posting_metrics(pages); hotpath::measure_block!("query.artifact.batch.receipts", { for page in pages { insert_prepared_source_page(&transaction, page)?; @@ -900,15 +973,24 @@ impl CodeLexicalArtifactBuilderV1 { } if load_finalization_state(&self.connection)?.is_none() { - verify_staged_source_tail(&self.connection, source)?; - let content_epoch = content_epoch(&self.connection)?; let transaction = self.connection.transaction().map_err(sqlite_error)?; let mut transaction_metrics = FinalizationTransactionMetricsV1::new(); + verify_staged_source_chain(&transaction, source, control)?; + let content_epoch = authenticated_authority_epoch(&transaction, source)?; + install_base_freeze(&transaction)?; store_finalization_state( &transaction, &PersistedFinalizationStateV1::new(content_epoch, source)?, )?; + checkpoint(control)?; commit_finalization_transaction(transaction, &mut transaction_metrics)?; + let step = CodeLexicalArtifactFinalizationStepV1::Pending { + phase: CodeLexicalArtifactFinalizationPhaseV1::IndexBuild, + completed_sections: 0, + completed_rows: 0, + }; + record_finalization_step(&step); + return Ok(step); } let transaction = self.connection.transaction().map_err(sqlite_error)?; @@ -928,6 +1010,19 @@ impl CodeLexicalArtifactBuilderV1 { )); } let mut remaining_work = maximum_work; + advance_pre_digest_work(&transaction, &mut state, &mut remaining_work, control)?; + if state.phase != PersistedFinalizationPhaseV1::Digest { + store_finalization_state(&transaction, &state)?; + checkpoint(control)?; + commit_finalization_transaction(transaction, &mut transaction_metrics)?; + let step = CodeLexicalArtifactFinalizationStepV1::Pending { + phase: state.phase.public(), + completed_sections: 0, + completed_rows: state.completed_rows, + }; + record_finalization_step(&step); + return Ok(step); + } let section_count = u64::try_from(SECTION_NAMES.len()).map_err(contract_number)?; while remaining_work > 0 && state.section_ordinal < section_count { checkpoint(control)?; @@ -950,29 +1045,7 @@ impl CodeLexicalArtifactBuilderV1 { } let section_digest = finish_persisted_section(section_name, &state)?; - match state.phase { - PersistedFinalizationPhaseV1::Build => { - state.completed_sections.push(section_digest) - } - PersistedFinalizationPhaseV1::Verify => { - let expected = - state - .completed_sections - .get(section_ordinal) - .ok_or_else(|| { - CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact verification has no matching build section" - .to_owned(), - ) - })?; - if §ion_digest != expected { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact changed between bounded finalization wakes" - .to_owned(), - )); - } - } - } + state.completed_sections.push(section_digest); state.section_ordinal = state.section_ordinal.checked_add(1).ok_or_else(|| { CodeLexicalArtifactErrorV1::Contract( "lexical artifact finalization section ordinal overflowed".to_owned(), @@ -1002,25 +1075,6 @@ impl CodeLexicalArtifactBuilderV1 { return Ok(step); } - if state.phase == PersistedFinalizationPhaseV1::Build { - state.phase = PersistedFinalizationPhaseV1::Verify; - state.section_ordinal = 0; - state.section_row_count = 0; - state.section_last_key = None; - state.section_accumulator = initial_section_accumulator(SECTION_NAMES[0])?.to_vec(); - store_finalization_state(&transaction, &state)?; - checkpoint(control)?; - commit_finalization_transaction(transaction, &mut transaction_metrics)?; - let step = CodeLexicalArtifactFinalizationStepV1::Pending { - phase: state.phase.public(), - completed_sections: u64::try_from(state.completed_sections.len()) - .map_err(contract_number)?, - completed_rows: state.completed_rows, - }; - record_finalization_step(&step); - return Ok(step); - } - if state.completed_sections.len() != SECTION_NAMES.len() { return Err(CodeLexicalArtifactErrorV1::Corrupt( "lexical artifact finalization completed with an invalid section receipt" @@ -1474,6 +1528,12 @@ fn batch_limit( } } +fn batch_ledger_overflow() -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch ledger charge overflowed".to_owned(), + ) +} + fn prepare_page_batch_admission( connection: &Connection, metadata: &CodeLexicalProjectionMetadataV1, @@ -1936,24 +1996,24 @@ fn append_prepared_postings( pages: &[PreparedCodeLexicalArtifactPageV1], control: &dyn CodeIndexExecutionControlV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { + let mut term_statement = transaction + .prepare_cached( + "INSERT INTO term_postings(field, term, document_id, frequency) VALUES (?1, ?2, ?3, ?4)", + ) + .map_err(sqlite_error)?; + let mut exact_statement = transaction + .prepare_cached( + "INSERT OR IGNORE INTO exact_postings(field, term, document_id) VALUES (?1, ?2, ?3)", + ) + .map_err(sqlite_error)?; + let mut ngram_statement = transaction + .prepare_cached("INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (?1, ?2, ?3)") + .map_err(sqlite_error)?; for page in pages { for document in &page.documents { checkpoint(control)?; - for (field, total_length) in &document.field_stats { - transaction - .prepare_cached( - "INSERT INTO field_stats(field, total_length) VALUES (?1, ?2) ON CONFLICT(field) DO UPDATE SET total_length = total_length + excluded.total_length", - ) - .map_err(sqlite_error)? - .execute(params![field.as_str(), total_length]) - .map_err(sqlite_error)?; - } for posting in &document.term_postings { - transaction - .prepare_cached( - "INSERT INTO term_postings(field, term, document_id, frequency) VALUES (?1, ?2, ?3, ?4)", - ) - .map_err(sqlite_error)? + term_statement .execute(params![ posting.field.as_str(), posting.term.as_str(), @@ -1961,36 +2021,18 @@ fn append_prepared_postings( posting.frequency ]) .map_err(sqlite_error)?; - transaction - .prepare_cached( - "INSERT INTO term_stats(field, term, document_frequency) VALUES (?1, ?2, 1) ON CONFLICT(field, term) DO UPDATE SET document_frequency = document_frequency + 1", - ) - .map_err(sqlite_error)? - .execute(params![posting.field.as_str(), posting.term.as_str()]) - .map_err(sqlite_error)?; - if posting.vocabulary { - transaction - .prepare_cached("INSERT OR IGNORE INTO vocabulary(term) VALUES (?1)") - .map_err(sqlite_error)? - .execute([&posting.term]) - .map_err(sqlite_error)?; - } } for (field, term) in &document.exact_postings { - transaction - .prepare_cached( - "INSERT OR IGNORE INTO exact_postings(field, term, document_id) VALUES (?1, ?2, ?3)", - ) - .map_err(sqlite_error)? - .execute(params![field.as_str(), term.as_slice(), document.document_id]) + exact_statement + .execute(params![ + field.as_str(), + term.as_slice(), + document.document_id + ]) .map_err(sqlite_error)?; } for (kind, ngram) in &document.ngram_postings { - transaction - .prepare_cached( - "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (?1, ?2, ?3)", - ) - .map_err(sqlite_error)? + ngram_statement .execute(params![kind, ngram, document.document_id]) .map_err(sqlite_error)?; } @@ -2004,24 +2046,30 @@ fn append_prepared_rows( pages: &[PreparedCodeLexicalArtifactPageV1], control: &dyn CodeIndexExecutionControlV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { + let mut row_statement = transaction + .prepare_cached("INSERT INTO rows(document_id, chunk_id, row) VALUES (?1, ?2, ?3)") + .map_err(sqlite_error)?; + let mut integrity_statement = transaction + .prepare_cached( + "INSERT INTO document_integrity(document_id, chunk_id, digest) VALUES (?1, ?2, ?3)", + ) + .map_err(sqlite_error)?; for page in pages { for document in &page.documents { checkpoint(control)?; - transaction - .execute( - "INSERT INTO rows(document_id, chunk_id, row) VALUES (?1, ?2, ?3)", - params![ - document.document_id, - document.chunk_id.as_str(), - document.row.as_slice() - ], - ) + row_statement + .execute(params![ + document.document_id, + document.chunk_id.as_str(), + document.row.as_slice() + ]) .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; - transaction - .execute( - "INSERT INTO document_integrity(document_id, digest) VALUES (?1, ?2)", - params![document.document_id, document.integrity_digest.as_str()], - ) + integrity_statement + .execute(params![ + document.document_id, + document.chunk_id.as_str(), + document.integrity_digest.as_str() + ]) .map_err(sqlite_error)?; } } @@ -2110,6 +2158,7 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError -- pre-seal mutation cannot attest itself without rereading source. CREATE TABLE document_integrity ( document_id INTEGER PRIMARY KEY, + chunk_id TEXT NOT NULL, digest TEXT NOT NULL ); CREATE TABLE import_integrity ( @@ -2122,7 +2171,7 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError ) WITHOUT ROWID; CREATE TABLE rows ( document_id INTEGER PRIMARY KEY, - chunk_id TEXT NOT NULL UNIQUE, + chunk_id TEXT NOT NULL, row BLOB NOT NULL ); CREATE TABLE term_postings ( @@ -2155,50 +2204,217 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError PRIMARY KEY(kind, ngram, document_id) ) WITHOUT ROWID; CREATE TABLE vocabulary (term TEXT PRIMARY KEY) WITHOUT ROWID; - CREATE INDEX term_postings_by_term ON term_postings(term, field, document_id); - CREATE INDEX term_postings_by_document ON term_postings(document_id, field, term, frequency); - CREATE INDEX term_postings_by_document_term ON term_postings(document_id, term, field, frequency); - CREATE INDEX term_stats_by_term ON term_stats(term, field); - CREATE INDEX exact_postings_by_document ON exact_postings(document_id, field, term); - CREATE INDEX ngram_postings_by_document ON ngram_postings(document_id, kind, ngram); CREATE TRIGGER content_epoch_source_pages_insert AFTER INSERT ON source_pages BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_source_pages_update AFTER UPDATE ON source_pages BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_source_pages_delete AFTER DELETE ON source_pages BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; CREATE TRIGGER content_epoch_document_integrity_insert AFTER INSERT ON document_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_document_integrity_update AFTER UPDATE ON document_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_document_integrity_delete AFTER DELETE ON document_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; CREATE TRIGGER content_epoch_import_integrity_insert AFTER INSERT ON import_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_import_integrity_update AFTER UPDATE ON import_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_import_integrity_delete AFTER DELETE ON import_integrity BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; CREATE TRIGGER content_epoch_import_evidence_insert AFTER INSERT ON import_evidence BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_import_evidence_update AFTER UPDATE ON import_evidence BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_import_evidence_delete AFTER DELETE ON import_evidence BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_rows_insert AFTER INSERT ON rows BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_rows_update AFTER UPDATE ON rows BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_rows_delete AFTER DELETE ON rows BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_postings_insert AFTER INSERT ON term_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_postings_update AFTER UPDATE ON term_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_postings_delete AFTER DELETE ON term_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_stats_insert AFTER INSERT ON term_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_stats_update AFTER UPDATE ON term_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_term_stats_delete AFTER DELETE ON term_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_field_stats_insert AFTER INSERT ON field_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_field_stats_update AFTER UPDATE ON field_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_field_stats_delete AFTER DELETE ON field_stats BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_exact_postings_insert AFTER INSERT ON exact_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_exact_postings_update AFTER UPDATE ON exact_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_exact_postings_delete AFTER DELETE ON exact_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_ngram_postings_insert AFTER INSERT ON ngram_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_ngram_postings_update AFTER UPDATE ON ngram_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_ngram_postings_delete AFTER DELETE ON ngram_postings BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_vocabulary_insert AFTER INSERT ON vocabulary BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_vocabulary_update AFTER UPDATE ON vocabulary BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; - CREATE TRIGGER content_epoch_vocabulary_delete AFTER DELETE ON vocabulary BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; + CREATE TRIGGER immutable_source_pages_update BEFORE UPDATE ON source_pages BEGIN SELECT RAISE(ABORT, 'immutable lexical source pages'); END; + CREATE TRIGGER immutable_source_pages_delete BEFORE DELETE ON source_pages BEGIN SELECT RAISE(ABORT, 'immutable lexical source pages'); END; + CREATE TRIGGER immutable_document_integrity_update BEFORE UPDATE ON document_integrity BEGIN SELECT RAISE(ABORT, 'immutable lexical document integrity'); END; + CREATE TRIGGER immutable_document_integrity_delete BEFORE DELETE ON document_integrity BEGIN SELECT RAISE(ABORT, 'immutable lexical document integrity'); END; + CREATE TRIGGER immutable_import_integrity_update BEFORE UPDATE ON import_integrity BEGIN SELECT RAISE(ABORT, 'immutable lexical import integrity'); END; + CREATE TRIGGER immutable_import_integrity_delete BEFORE DELETE ON import_integrity BEGIN SELECT RAISE(ABORT, 'immutable lexical import integrity'); END; + CREATE TRIGGER immutable_import_evidence_update BEFORE UPDATE ON import_evidence BEGIN SELECT RAISE(ABORT, 'immutable lexical import evidence'); END; + CREATE TRIGGER immutable_import_evidence_delete BEFORE DELETE ON import_evidence BEGIN SELECT RAISE(ABORT, 'immutable lexical import evidence'); END; ", ) .map_err(sqlite_error) } +fn install_base_freeze(transaction: &Transaction<'_>) -> Result<(), CodeLexicalArtifactErrorV1> { + transaction + .execute_batch( + " + CREATE TRIGGER frozen_source_pages_insert BEFORE INSERT ON source_pages BEGIN SELECT RAISE(ABORT, 'frozen lexical source pages'); END; + CREATE TRIGGER frozen_document_integrity_insert BEFORE INSERT ON document_integrity BEGIN SELECT RAISE(ABORT, 'frozen lexical document integrity'); END; + CREATE TRIGGER frozen_import_integrity_insert BEFORE INSERT ON import_integrity BEGIN SELECT RAISE(ABORT, 'frozen lexical import integrity'); END; + CREATE TRIGGER frozen_import_evidence_insert BEFORE INSERT ON import_evidence BEGIN SELECT RAISE(ABORT, 'frozen lexical import evidence'); END; + CREATE TRIGGER frozen_rows_insert BEFORE INSERT ON rows BEGIN SELECT RAISE(ABORT, 'frozen lexical rows'); END; + CREATE TRIGGER frozen_rows_update BEFORE UPDATE ON rows BEGIN SELECT RAISE(ABORT, 'frozen lexical rows'); END; + CREATE TRIGGER frozen_rows_delete BEFORE DELETE ON rows BEGIN SELECT RAISE(ABORT, 'frozen lexical rows'); END; + CREATE TRIGGER frozen_term_postings_insert BEFORE INSERT ON term_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical term postings'); END; + CREATE TRIGGER frozen_term_postings_update BEFORE UPDATE ON term_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical term postings'); END; + CREATE TRIGGER frozen_term_postings_delete BEFORE DELETE ON term_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical term postings'); END; + CREATE TRIGGER frozen_exact_postings_insert BEFORE INSERT ON exact_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical exact postings'); END; + CREATE TRIGGER frozen_exact_postings_update BEFORE UPDATE ON exact_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical exact postings'); END; + CREATE TRIGGER frozen_exact_postings_delete BEFORE DELETE ON exact_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical exact postings'); END; + CREATE TRIGGER frozen_ngram_postings_insert BEFORE INSERT ON ngram_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical ngram postings'); END; + CREATE TRIGGER frozen_ngram_postings_update BEFORE UPDATE ON ngram_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical ngram postings'); END; + CREATE TRIGGER frozen_ngram_postings_delete BEFORE DELETE ON ngram_postings BEGIN SELECT RAISE(ABORT, 'frozen lexical ngram postings'); END; + ", + ) + .map_err(sqlite_error) +} + +fn authenticated_authority_epoch( + transaction: &Transaction<'_>, + source: &VerifiedSealedLexicalSourceReceiptV1, +) -> Result { + let (pages, documents, import_integrity, import_evidence): (i64, i64, i64, i64) = transaction + .query_row( + "SELECT (SELECT COUNT(*) FROM source_pages), (SELECT COUNT(*) FROM document_integrity), (SELECT COUNT(*) FROM import_integrity), (SELECT COUNT(*) FROM import_evidence)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .map_err(sqlite_error)?; + let expected_epoch = pages + .checked_add(documents) + .and_then(|count| count.checked_add(import_integrity)) + .and_then(|count| count.checked_add(import_evidence)) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact authority row count overflowed".to_owned(), + ) + })?; + let actual_epoch = content_epoch(transaction)?; + if actual_epoch != expected_epoch + || u64::try_from(pages).ok() != Some(source.page_count()) + || u64::try_from(documents).ok() != Some(source.total_chunks()) + || u64::try_from(import_integrity).ok() != Some(source.total_imports()) + || import_integrity != import_evidence + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact authenticated authority disagrees with its source receipt".to_owned(), + )); + } + let orphaned: i64 = transaction + .query_row( + "SELECT EXISTS( + SELECT 1 FROM rows AS content LEFT JOIN document_integrity AS authority USING(document_id) WHERE authority.document_id IS NULL + UNION ALL SELECT 1 FROM term_postings AS content LEFT JOIN document_integrity AS authority USING(document_id) WHERE authority.document_id IS NULL + UNION ALL SELECT 1 FROM exact_postings AS content LEFT JOIN document_integrity AS authority USING(document_id) WHERE authority.document_id IS NULL + UNION ALL SELECT 1 FROM ngram_postings AS content LEFT JOIN document_integrity AS authority USING(document_id) WHERE authority.document_id IS NULL + )", + [], + |row| row.get(0), + ) + .map_err(sqlite_error)?; + let mismatched_rows: i64 = transaction + .query_row( + "SELECT EXISTS(SELECT 1 FROM document_integrity AS authority LEFT JOIN rows AS content USING(document_id) WHERE content.document_id IS NULL OR content.chunk_id != authority.chunk_id)", + [], + |row| row.get(0), + ) + .map_err(sqlite_error)?; + if orphaned != 0 || mismatched_rows != 0 { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base rows are not covered by authenticated document authority" + .to_owned(), + )); + } + Ok(actual_epoch) +} + +fn advance_pre_digest_work( + transaction: &Transaction<'_>, + state: &mut PersistedFinalizationStateV1, + remaining_work: &mut usize, + control: &dyn CodeIndexExecutionControlV1, +) -> Result<(), CodeLexicalArtifactErrorV1> { + while *remaining_work > 0 && state.phase != PersistedFinalizationPhaseV1::Digest { + checkpoint(control)?; + match state.phase { + PersistedFinalizationPhaseV1::Statistics => { + derive_statistics_step(transaction, state.section_ordinal)?; + state.section_ordinal += 1; + if state.section_ordinal == 3 { + state.phase = PersistedFinalizationPhaseV1::Indexes; + state.section_ordinal = 0; + } + } + PersistedFinalizationPhaseV1::Indexes => { + build_serving_index_step(transaction, state.section_ordinal)?; + state.section_ordinal += 1; + if state.section_ordinal == 7 { + verify_required_artifact_indexes(transaction)?; + state.phase = PersistedFinalizationPhaseV1::Digest; + state.section_ordinal = 0; + state.section_accumulator = + initial_section_accumulator(SECTION_NAMES[0])?.to_vec(); + } + } + PersistedFinalizationPhaseV1::Digest => {} + } + *remaining_work -= 1; + checkpoint(control)?; + } + Ok(()) +} + +fn derive_statistics_step( + transaction: &Transaction<'_>, + ordinal: u64, +) -> Result<(), CodeLexicalArtifactErrorV1> { + match ordinal { + 0 => hotpath::measure_block!("query.artifact.finalization.derive_field_stats", { + transaction.execute_batch( + "INSERT INTO field_stats(field, total_length) SELECT field, SUM(frequency) FROM term_postings GROUP BY field; + CREATE TRIGGER frozen_field_stats_insert BEFORE INSERT ON field_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical field statistics'); END; + CREATE TRIGGER frozen_field_stats_update BEFORE UPDATE ON field_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical field statistics'); END; + CREATE TRIGGER frozen_field_stats_delete BEFORE DELETE ON field_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical field statistics'); END;", + ) + }), + 1 => hotpath::measure_block!("query.artifact.finalization.derive_term_stats", { + transaction.execute_batch( + "INSERT INTO term_stats(field, term, document_frequency) SELECT field, term, COUNT(*) FROM term_postings GROUP BY field, term; + CREATE TRIGGER frozen_term_stats_insert BEFORE INSERT ON term_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical term statistics'); END; + CREATE TRIGGER frozen_term_stats_update BEFORE UPDATE ON term_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical term statistics'); END; + CREATE TRIGGER frozen_term_stats_delete BEFORE DELETE ON term_stats BEGIN SELECT RAISE(ABORT, 'frozen lexical term statistics'); END;", + ) + }), + 2 => hotpath::measure_block!("query.artifact.finalization.derive_vocabulary", { + let subtoken = encode_field(LexicalFieldV1::Subtoken)?; + transaction + .execute( + "INSERT INTO vocabulary(term) SELECT DISTINCT term FROM term_postings WHERE field != ?1", + [subtoken], + ) + .and_then(|_| { + transaction.execute_batch( + "CREATE TRIGGER frozen_vocabulary_insert BEFORE INSERT ON vocabulary BEGIN SELECT RAISE(ABORT, 'frozen lexical vocabulary'); END; + CREATE TRIGGER frozen_vocabulary_update BEFORE UPDATE ON vocabulary BEGIN SELECT RAISE(ABORT, 'frozen lexical vocabulary'); END; + CREATE TRIGGER frozen_vocabulary_delete BEFORE DELETE ON vocabulary BEGIN SELECT RAISE(ABORT, 'frozen lexical vocabulary'); END;", + ) + }) + }), + _ => { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact selected an unknown statistics step".to_owned(), + )); + } + } + .map_err(sqlite_error)?; + Ok(()) +} + +fn build_serving_index_step( + transaction: &Transaction<'_>, + ordinal: u64, +) -> Result<(), CodeLexicalArtifactErrorV1> { + let sql = match ordinal { + 0 => "CREATE UNIQUE INDEX rows_by_chunk ON rows(chunk_id)", + 1 => "CREATE INDEX term_postings_by_term ON term_postings(term, field, document_id)", + 2 => { + "CREATE INDEX term_postings_by_document ON term_postings(document_id, field, term, frequency)" + } + 3 => { + "CREATE INDEX term_postings_by_document_term ON term_postings(document_id, term, field, frequency)" + } + 4 => "CREATE INDEX term_stats_by_term ON term_stats(term, field)", + 5 => "CREATE INDEX exact_postings_by_document ON exact_postings(document_id, field, term)", + 6 => "CREATE INDEX ngram_postings_by_document ON ngram_postings(document_id, kind, ngram)", + _ => { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact selected an unknown serving-index step".to_owned(), + )); + } + }; + hotpath::measure_block!( + "query.artifact.finalization.build_serving_index", + transaction.execute_batch(sql).map_err(sqlite_error) + ) +} + impl PersistedFinalizationStateV1 { fn new( content_epoch: i64, @@ -2210,7 +2426,7 @@ impl PersistedFinalizationStateV1 { )); } Ok(Self { - phase: PersistedFinalizationPhaseV1::Build, + phase: PersistedFinalizationPhaseV1::Statistics, section_ordinal: 0, section_row_count: 0, section_last_key: None, @@ -2333,13 +2549,17 @@ fn validate_finalization_state( state: &PersistedFinalizationStateV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { let section_count = u64::try_from(SECTION_NAMES.len()).map_err(contract_number)?; - let completed_section_count = match state.phase { - PersistedFinalizationPhaseV1::Build => { - usize::try_from(state.section_ordinal).map_err(contract_number)? - } - PersistedFinalizationPhaseV1::Verify => SECTION_NAMES.len(), + let completed_section_count = if state.phase == PersistedFinalizationPhaseV1::Digest { + usize::try_from(state.section_ordinal).map_err(contract_number)? + } else { + 0 + }; + let maximum_ordinal = match state.phase { + PersistedFinalizationPhaseV1::Statistics => 3, + PersistedFinalizationPhaseV1::Indexes => 7, + PersistedFinalizationPhaseV1::Digest => section_count, }; - if state.section_ordinal > section_count + if state.section_ordinal > maximum_ordinal || state.completed_sections.len() != completed_section_count || state.completed_sections.len() > SECTION_NAMES.len() || state.section_accumulator.len() != 32 @@ -2360,6 +2580,11 @@ fn validate_finalization_state( )); } if let Some(key) = &state.section_last_key { + if state.phase != PersistedFinalizationPhaseV1::Digest { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "persisted lexical artifact pre-digest state has a row key".to_owned(), + )); + } let section_ordinal = usize::try_from(state.section_ordinal).map_err(contract_number)?; let section = FinalizationSectionV1::from_ordinal(section_ordinal)?; if !key.matches_section(section) { @@ -2614,7 +2839,13 @@ fn verify_integrity_row( row: &rusqlite::Row<'_>, control: &dyn CodeIndexExecutionControlV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { - let expected: String = row.get(1).map_err(sqlite_error)?; + let expected: String = row + .get(if section == FinalizationSectionV1::DocumentIntegrity { + 2 + } else { + 1 + }) + .map_err(sqlite_error)?; let actual = match section { FinalizationSectionV1::DocumentIntegrity => match row.get_ref(0).map_err(sqlite_error)? { ValueRef::Integer(document) if document >= 0 => { @@ -2744,30 +2975,81 @@ fn finish_section( /// counting/replaying every staged page on each bounded finalization wake. /// The final section receipts validate the full source-page cardinality before /// a sealed artifact is published. -fn verify_staged_source_tail( +fn verify_staged_source_chain( connection: &Connection, source: &VerifiedSealedLexicalSourceReceiptV1, + control: &dyn CodeIndexExecutionControlV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { - let cursor = match source.page_count().checked_sub(1) { - Some(page_ordinal) => connection - .query_row( - "SELECT next_cursor FROM source_pages WHERE page_ordinal = ?1", - [i64::try_from(page_ordinal).map_err(contract_number)?], - |row| row.get::<_, Vec>(0), - ) - .optional() - .map_err(sqlite_error)? - .as_deref() - .map(decode_cursor) - .transpose()?, - None => None, - }; + let mut statement = connection + .prepare( + "SELECT page_ordinal, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor FROM source_pages ORDER BY page_ordinal", + ) + .map_err(sqlite_error)?; + let mut rows = statement.query([]).map_err(sqlite_error)?; + let mut expected_ordinal = 0u64; + let mut chunks = 0u64; + let mut payload_bytes = 0u64; + let mut imports = 0u64; + let mut import_payload_bytes = 0u64; + let mut terminal = None; + while let Some(row) = rows.next().map_err(sqlite_error)? { + checkpoint(control)?; + let ordinal = + u64::try_from(row.get::<_, i64>(0).map_err(sqlite_error)?).map_err(contract_number)?; + let cumulative_digest: String = row.get(1).map_err(sqlite_error)?; + let page_chunks = + u64::try_from(row.get::<_, i64>(2).map_err(sqlite_error)?).map_err(contract_number)?; + let page_payload = + u64::try_from(row.get::<_, i64>(3).map_err(sqlite_error)?).map_err(contract_number)?; + let page_imports = + u64::try_from(row.get::<_, i64>(4).map_err(sqlite_error)?).map_err(contract_number)?; + let page_import_payload = + u64::try_from(row.get::<_, i64>(5).map_err(sqlite_error)?).map_err(contract_number)?; + let import_digest: String = row.get(6).map_err(sqlite_error)?; + let cursor_bytes: Vec = row.get(7).map_err(sqlite_error)?; + let cursor = decode_cursor(&cursor_bytes)?; + chunks = chunks + .checked_add(page_chunks) + .ok_or_else(source_chain_overflow)?; + payload_bytes = payload_bytes + .checked_add(page_payload) + .ok_or_else(source_chain_overflow)?; + imports = imports + .checked_add(page_imports) + .ok_or_else(source_chain_overflow)?; + import_payload_bytes = import_payload_bytes + .checked_add(page_import_payload) + .ok_or_else(source_chain_overflow)?; + if ordinal != expected_ordinal + || cursor.next_page_ordinal() != expected_ordinal + 1 + || cursor.emitted_chunks() != chunks + || cursor.emitted_payload_bytes() != payload_bytes + || cursor.emitted_imports() != imports + || cursor.emitted_import_payload_bytes() != import_payload_bytes + || cursor.cumulative_digest().as_str() != cumulative_digest + || cursor.import_dictionary_digest().as_str() != import_digest + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact source-page cursor chain is inconsistent".to_owned(), + )); + } + expected_ordinal = expected_ordinal + .checked_add(1) + .ok_or_else(source_chain_overflow)?; + terminal = Some(cursor); + } source - .verify_completion(cursor.as_ref()) + .verify_completion(terminal.as_ref()) .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; Ok(()) } +fn source_chain_overflow() -> CodeLexicalArtifactErrorV1 { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact source-page chain counter overflowed".to_owned(), + ) +} + fn verify_sealed_receipt_header( receipt: &VerifiedCodeLexicalArtifactV1, expected_metadata_digest: &ManifestDigest, @@ -2818,13 +3100,13 @@ fn document_integrity_digest( ) -> Result { checkpoint(control)?; let mut hasher = Sha256::new(); - hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v1\0"); + hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v2\0"); hasher.update(document.to_le_bytes()); let row_count = hash_document_table( transaction, &mut hasher, "row", - "SELECT row FROM rows WHERE document_id = ?1", + "SELECT chunk_id, row FROM rows WHERE document_id = ?1", document, control, )?; @@ -3537,6 +3819,14 @@ mod tests { .expect("seed ngram posting"); } transaction.commit().expect("commit seed transaction"); + let transaction = connection + .transaction() + .expect("start index-build transaction"); + for ordinal in [2, 5, 6] { + build_serving_index_step(&transaction, ordinal) + .expect("build document integrity index"); + } + transaction.commit().expect("commit integrity index"); for query in [ DOCUMENT_TERM_POSTINGS_QUERY, @@ -3583,17 +3873,18 @@ mod tests { ) .expect("seed term posting"); } + build_serving_index_step(&transaction, 2).expect("build document integrity index"); let digest = document_integrity_digest(&transaction, 0, &ActiveControl) .expect("derive append receipt"); transaction .execute( - "INSERT INTO document_integrity(document_id, digest) VALUES (0, ?1)", + "INSERT INTO document_integrity(document_id, chunk_id, digest) VALUES (0, 'chunk', ?1)", [digest.as_str()], ) .expect("seed document receipt"); let mut state = PersistedFinalizationStateV1 { - phase: PersistedFinalizationPhaseV1::Build, + phase: PersistedFinalizationPhaseV1::Digest, section_ordinal: 1, section_row_count: 0, section_last_key: None, @@ -3643,7 +3934,7 @@ mod tests { .expect("seed source page"); connection .execute( - "INSERT INTO document_integrity(document_id, digest) VALUES (0, 'document')", + "INSERT INTO document_integrity(document_id, chunk_id, digest) VALUES (0, 'chunk', 'document')", [], ) .expect("seed document integrity"); diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs index 4f027760d1..33abf93e24 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs @@ -18,13 +18,19 @@ use super::CodeLexicalArtifactErrorV1; /// are branch-only staging files and must fail as incompatible rather than be /// partially interpreted against this schema. // Revision 3 replaces the branch-local computed finalization cursor with -// native table keys. Revision 4 adds document-leading indexes so verifying -// one document's receipt never scans an unrelated generation. Revision 5 -// adds the term-leading statistics index and term-selective document index -// required by batched lexical reads. -pub(super) const CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1: u32 = 5; -const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"tracedecay.code-lexical-artifact.v5\0"; -const REQUIRED_ARTIFACT_INDEXES_V5: [(&str, &str, &[&str]); 3] = [ +// native table keys. Revision 4 adds document-leading indexes. Revision 5 +// adds term-selective read indexes. Revision 6 makes the append authority +// immutable before one authenticated digest pass and defers every serving +// index until resumable finalization. +pub(super) const CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1: u32 = 6; +const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"tracedecay.code-lexical-artifact.v6\0"; +const REQUIRED_ARTIFACT_INDEXES_V6: [(&str, &str, &[&str]); 7] = [ + ("rows", "rows_by_chunk", &["chunk_id"]), + ( + "term_postings", + "term_postings_by_term", + &["term", "field", "document_id"], + ), ( "term_postings", "term_postings_by_document", @@ -36,6 +42,16 @@ const REQUIRED_ARTIFACT_INDEXES_V5: [(&str, &str, &[&str]); 3] = [ &["document_id", "term", "field", "frequency"], ), ("term_stats", "term_stats_by_term", &["term", "field"]), + ( + "exact_postings", + "exact_postings_by_document", + &["document_id", "field", "term"], + ), + ( + "ngram_postings", + "ngram_postings_by_document", + &["document_id", "kind", "ngram"], + ), ]; pub(super) const RECEIPT_RESERVATION_BYTES: usize = 16 * 1024; pub(super) const SECTION_NAMES: [&str; 11] = [ @@ -62,7 +78,7 @@ pub(super) fn verify_required_artifact_indexes( "artifact index schema is unreadable: {error}" )) })?; - for (table, index, expected_columns) in REQUIRED_ARTIFACT_INDEXES_V5 { + for (table, index, expected_columns) in REQUIRED_ARTIFACT_INDEXES_V6 { let partial: Option = connection .query_row( "SELECT partial FROM pragma_index_list(?1) WHERE name = ?2", diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs index a4ed62afc6..54ceaf7e69 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs @@ -4,15 +4,13 @@ use sha2::{Digest, Sha256}; use tracedecay_code_index::production::{CodeIndexExecutionControlV1, VerifiedSealedLexicalPageV1}; use tracedecay_domain::{ExactFieldV1, ManifestDigest}; -use super::format::{ArtifactRowV1, encode_exact_field, encode_field}; -use super::postings::{NGRAM_NORMALIZED, NGRAM_RAW_OVERRIDE, document_ngrams}; -use super::{CodeLexicalArtifactErrorV1, checkpoint}; -use crate::retrieval::lexical::LexicalFieldV1; - use super::super::{ CodeLexicalProjectionMetadataV1, ProjectedChunkV1, canonical_projected_exact_term, exact_field_for_kind, }; +use super::format::{ArtifactRowV1, encode_exact_field, encode_field}; +use super::postings::{NGRAM_NORMALIZED, NGRAM_RAW_OVERRIDE, document_ngrams}; +use super::{CodeLexicalArtifactErrorV1, checkpoint}; #[derive(Debug)] pub struct PreparedCodeLexicalArtifactPageV1 { @@ -91,7 +89,6 @@ pub(super) struct PreparedDocumentV1 { pub(super) document_id: i64, pub(super) chunk_id: String, pub(super) row: Vec, - pub(super) field_stats: Vec<(String, i64)>, pub(super) term_postings: Vec, pub(super) exact_postings: Vec<(String, Vec)>, pub(super) ngram_postings: Vec<(i64, i64)>, @@ -103,7 +100,6 @@ pub(super) struct PreparedTermPostingV1 { pub(super) field: String, pub(super) term: String, pub(super) frequency: i64, - pub(super) vocabulary: bool, } pub(super) fn prepare_page( @@ -209,15 +205,10 @@ fn prepare_document( )) })?; let (row, fields) = ProjectedChunkV1::from_ref(chunk, logical_path); - let mut field_stats = Vec::with_capacity(fields.len()); let mut term_postings = Vec::new(); for (field, terms) in &fields { checkpoint(control)?; let encoded_field = encode_field(*field)?; - field_stats.push(( - encoded_field.clone(), - i64::try_from(terms.len()).map_err(contract_number)?, - )); let mut frequencies = BTreeMap::<&str, u32>::new(); for term in terms { frequencies @@ -230,11 +221,9 @@ fn prepare_document( field: encoded_field.clone(), term: term.to_owned(), frequency: i64::from(frequency), - vocabulary: *field != LexicalFieldV1::Subtoken, }); } } - field_stats.sort_unstable_by(|left, right| left.0.cmp(&right.0)); term_postings.sort_unstable_by(|left, right| { (&left.field, &left.term).cmp(&(&right.field, &right.term)) }); @@ -277,6 +266,7 @@ fn prepare_document( let exact_postings = exact_postings.into_iter().collect::>(); let integrity_digest = document_integrity_digest( document_id, + chunk_id.as_bytes(), &row, &term_postings, &exact_postings, @@ -286,7 +276,6 @@ fn prepare_document( document_id, chunk_id, row, - field_stats, term_postings, exact_postings, ngram_postings, @@ -296,15 +285,19 @@ fn prepare_document( fn document_integrity_digest( document: i64, + chunk_id: &[u8], row: &[u8], term_postings: &[PreparedTermPostingV1], exact_postings: &[(String, Vec)], ngram_postings: &[(i64, i64)], ) -> Result { let mut hasher = Sha256::new(); - hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v1\0"); + hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v2\0"); hasher.update(document.to_le_bytes()); - hash_table(&mut hasher, "row", 1, |hasher, _| hash_blob(hasher, row))?; + hash_table(&mut hasher, "row", 1, |hasher, _| { + hash_text(hasher, chunk_id)?; + hash_blob(hasher, row) + })?; hash_table( &mut hasher, "term_posting", @@ -444,14 +437,6 @@ fn prepared_retained_bytes( .checked_add(document.chunk_id.capacity()) .and_then(|bytes| bytes.checked_add(document.row.capacity())) .and_then(|bytes| bytes.checked_add(document.integrity_digest.as_str().len())) - .and_then(|bytes| { - bytes.checked_add( - document - .field_stats - .capacity() - .saturating_mul(std::mem::size_of::<(String, i64)>()), - ) - }) .and_then(|bytes| { bytes.checked_add( document @@ -477,11 +462,6 @@ fn prepared_retained_bytes( ) }) .ok_or_else(prepared_charge_overflow)?; - for (field, _) in &document.field_stats { - bytes = bytes - .checked_add(field.capacity()) - .ok_or_else(prepared_charge_overflow)?; - } for posting in &document.term_postings { bytes = bytes .checked_add(posting.field.capacity()) @@ -524,33 +504,10 @@ fn estimated_sqlite_writes( .and_then(|bytes| bytes.checked_add(document.row.len())) .and_then(|bytes| bytes.checked_add(document.integrity_digest.as_str().len())) .ok_or_else(prepared_write_overflow)?; - for (field, _) in &document.field_stats { - rows = rows.checked_add(1).ok_or_else(prepared_write_overflow)?; - bytes = bytes - .checked_add(field.len()) - .and_then(|bytes| bytes.checked_add(16)) - .ok_or_else(prepared_write_overflow)?; - } for posting in &document.term_postings { - // Posting, term statistic, and optional vocabulary mutation. - rows = rows - .checked_add(2 + usize::from(posting.vocabulary)) - .ok_or_else(prepared_write_overflow)?; + rows = rows.checked_add(1).ok_or_else(prepared_write_overflow)?; bytes = bytes - .checked_add( - posting - .field - .len() - .saturating_add(posting.term.len()) - .saturating_mul(2), - ) - .and_then(|bytes| { - bytes.checked_add(if posting.vocabulary { - posting.term.len() - } else { - 0 - }) - }) + .checked_add(posting.field.len().saturating_add(posting.term.len())) .and_then(|bytes| bytes.checked_add(32)) .ok_or_else(prepared_write_overflow)?; } 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 a67d6c60c4..7734a0aebe 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::io::Cursor; +use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -525,7 +526,9 @@ fn sealed_source_rejected_batch_retries_byte_identical_pages_and_cursor() { ); let retried = source - .next_page_batch_if(&control, bounds, |_| Ok::<(), &'static str>(())) + .next_page_batch_if(&control, bounds, |pages| { + Ok::<_, &'static str>(NonZeroUsize::new(pages.len()).expect("non-empty source batch")) + }) .expect("retry staged batch") .expect("accept retried batch"); let VerifiedSealedLexicalPageBatchReadV1::Pages(retried_pages) = retried else { @@ -567,7 +570,7 @@ fn sealed_source_batch_bounds_and_completion_never_advance_empty_work() { source .next_page_batch_if(&control, too_small, |_| { callbacks.fetch_add(1, Ordering::SeqCst); - Ok::<(), ()>(()) + Ok::<_, ()>(NonZeroUsize::MIN) }) .is_err(), "a first page above the retained-byte bound is a typed source error" @@ -583,9 +586,9 @@ fn sealed_source_batch_bounds_and_completion_never_advance_empty_work() { loop { let before = callbacks.load(Ordering::SeqCst); let read = source - .next_page_batch_if(&control, bounds, |_| { + .next_page_batch_if(&control, bounds, |pages| { callbacks.fetch_add(1, Ordering::SeqCst); - Ok::<(), ()>(()) + Ok::<_, ()>(NonZeroUsize::new(pages.len()).expect("non-empty source batch")) }) .expect("drain source") .expect("accept source batch"); @@ -648,9 +651,18 @@ impl TestArtifactSourceStaging for CodeLexicalArtifactBuilderV1 { .last() .is_some_and(|page| page.page_ordinal() < staged_pages) { - Ok(()) + Ok(NonZeroUsize::new(pages.len()).expect("non-empty source batch")) } else { - self.append_pages(pages, control).map(|_| ()) + let accepted = self.largest_admissible_page_prefix(pages)?; + let Some(accepted) = NonZeroUsize::new(accepted) else { + self.append_pages(&pages[..1], control)?; + return Err(CodeLexicalArtifactErrorV1::Contract( + "lexical artifact admitted no pages without returning its typed bound" + .to_owned(), + )); + }; + self.append_pages(&pages[..accepted.get()], control)?; + Ok(accepted) } }) .map_err(|error| match error { @@ -1223,6 +1235,153 @@ fn reader_rejects_current_artifact_missing_required_term_statistics_index() { )); } +#[test] +fn disk_artifact_defers_statistics_and_serving_indexes_until_freeze() { + let (fixture, pages, source_receipt) = real_verified_pages(); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("deferred-serving-state.sqlite"); + let control = ArtifactControl { cancelled: false }; + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) + .expect("create artifact"); + for page in &pages { + builder.append_page(page, &control).expect("append page"); + } + + let connection = rusqlite::Connection::open(&artifact_path).expect("inspect staging artifact"); + let staging_indexes: Vec = connection + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex_%' ORDER BY name", + ) + .expect("prepare index inventory") + .query_map([], |row| row.get(0)) + .expect("query index inventory") + .collect::>() + .expect("read index inventory"); + assert_eq!(staging_indexes, Vec::::new()); + for table in ["field_stats", "term_stats", "vocabulary"] { + let rows: i64 = connection + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .expect("count deferred statistic rows"); + assert_eq!(rows, 0, "{table} must be derived after the base freeze"); + } + let authority_rows: i64 = connection + .query_row( + "SELECT (SELECT COUNT(*) FROM source_pages) + \ + (SELECT COUNT(*) FROM document_integrity) + \ + (SELECT COUNT(*) FROM import_integrity) + \ + (SELECT COUNT(*) FROM import_evidence)", + [], + |row| row.get(0), + ) + .expect("count authenticated authority rows"); + let epoch: i64 = connection + .query_row( + "SELECT epoch FROM content_epoch WHERE singleton = 1", + [], + |row| row.get(0), + ) + .expect("read authenticated authority epoch"); + assert_eq!(epoch, authority_rows); + drop(connection); + + assert!(matches!( + builder + .advance_finalization(&source_receipt, 1, &control) + .expect("persist base freeze"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); + let connection = rusqlite::Connection::open(&artifact_path).expect("inspect frozen artifact"); + assert!( + connection + .execute( + "UPDATE rows SET row = row WHERE document_id = (SELECT MIN(document_id) FROM rows)", + [], + ) + .is_err(), + "the persisted freeze must deny base-row mutation" + ); + drop(connection); + + let verified = finish_staged_artifact(&mut builder, &source_receipt, &control); + assert_eq!(verified.total_chunks(), source_receipt.total_chunks()); + let connection = rusqlite::Connection::open(&artifact_path).expect("inspect sealed artifact"); + let serving_indexes: Vec = connection + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex_%' ORDER BY name", + ) + .expect("prepare final index inventory") + .query_map([], |row| row.get(0)) + .expect("query final index inventory") + .collect::>() + .expect("read final index inventory"); + assert_eq!( + serving_indexes, + [ + "exact_postings_by_document", + "ngram_postings_by_document", + "rows_by_chunk", + "term_postings_by_document", + "term_postings_by_document_term", + "term_postings_by_term", + "term_stats_by_term", + ] + ); + let incorrect_field_stats: i64 = connection + .query_row( + "SELECT COUNT(*) FROM field_stats AS actual LEFT JOIN (SELECT field, SUM(frequency) AS total_length FROM term_postings GROUP BY field) AS expected USING(field) WHERE actual.total_length != expected.total_length", + [], + |row| row.get(0), + ) + .expect("compare field statistics"); + let incorrect_term_stats: i64 = connection + .query_row( + "SELECT COUNT(*) FROM term_stats AS actual LEFT JOIN (SELECT field, term, COUNT(*) AS document_frequency FROM term_postings GROUP BY field, term) AS expected USING(field, term) WHERE actual.document_frequency != expected.document_frequency", + [], + |row| row.get(0), + ) + .expect("compare term statistics"); + assert_eq!(incorrect_field_stats, 0); + assert_eq!(incorrect_term_stats, 0); +} + +#[test] +fn disk_artifact_admission_selects_the_exact_largest_contiguous_prefix() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + assert!( + pages.len() >= 2, + "fixture must expose a real prefix boundary" + ); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let probe_path = directory.path().join("prefix-probe.sqlite"); + let probe = CodeLexicalArtifactBuilderV1::create(&probe_path, fixture.metadata.clone()) + .expect("create admission probe"); + let first_page_charge = probe + .page_batch_ledger_charge_bytes(&pages[..1]) + .expect("measure first page charge"); + let exact_budget = probe + .fixed_ledger_charge_bytes() + .checked_add(first_page_charge) + .expect("exact first-page budget"); + drop(probe); + + let artifact_path = directory.path().join("prefix-bound.sqlite"); + let builder = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &artifact_path, + fixture.metadata, + exact_budget, + ) + .expect("create exactly bounded builder"); + assert_eq!( + builder + .largest_admissible_page_prefix(&pages) + .expect("select admissible prefix"), + 1, + "the selector must accept the equality boundary and stop before the first over-budget page" + ); +} + #[test] fn disk_artifact_finalization_resumes_after_restart_without_source_replay() { let (mut fixture, pages, source_receipt) = real_verified_pages_with_maximum_page_chunks(1); @@ -1382,9 +1541,6 @@ fn disk_artifact_revision_four_is_incompatible_before_new_index_queries() { [], ) .expect("write revision-four artifact state"); - connection - .execute_batch("DROP INDEX term_stats_by_term;") - .expect("remove revision-five index"); drop(connection); assert!(matches!( @@ -1400,16 +1556,30 @@ fn disk_artifact_revision_four_is_incompatible_before_new_index_queries() { #[test] fn disk_artifact_resume_rejects_current_revision_with_wrong_term_index_shape() { - let (fixture, pages, _) = real_verified_pages(); + let (fixture, pages, source_receipt) = real_verified_pages(); let metadata = fixture.metadata.clone(); let directory = tempfile::tempdir().expect("artifact tempdir"); let artifact_path = directory.path().join("wrong-term-index-shape.sqlite"); let control = ArtifactControl { cancelled: false }; let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, metadata.clone()).expect("create"); - builder - .append_page(&pages[0], &control) - .expect("stage current-format source page"); + for page in &pages { + builder + .append_page(page, &control) + .expect("stage current-format source page"); + } + assert!(matches!( + builder + .advance_finalization(&source_receipt, 1, &control) + .expect("freeze current artifact"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); + assert!(matches!( + builder + .advance_finalization(&source_receipt, 10, &control) + .expect("build serving indexes"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); drop(builder); let connection = rusqlite::Connection::open(&artifact_path).expect("open index mutation"); @@ -1455,18 +1625,18 @@ fn disk_artifact_finalization_refuses_inter_wake_mutation() { CodeLexicalArtifactFinalizationStepV1::Pending { .. } )); let connection = rusqlite::Connection::open(&artifact_path).expect("open artifact mutation"); - connection - .execute( - "UPDATE rows SET row = row WHERE document_id = (SELECT MIN(document_id) FROM rows)", - [], - ) - .expect("perform a structurally valid inter-wake mutation"); + assert!( + connection + .execute( + "UPDATE rows SET row = row WHERE document_id = (SELECT MIN(document_id) FROM rows)", + [], + ) + .is_err(), + "the persisted freeze must reject inter-wake mutation" + ); drop(connection); - assert!(matches!( - builder.advance_finalization(&source_receipt, 1, &control), - Err(CodeLexicalArtifactErrorV1::Corrupt(_)) - )); + finish_staged_artifact(&mut builder, &source_receipt, &control); assert_eq!( builder.progress().expect("source progress after refusal"), staged, @@ -1543,21 +1713,23 @@ fn disk_artifact_mandatory_verifier_rejects_tampered_real_source_chain() { .append_page(&pages[0], &control) .expect("append canonical first page"); let connection = rusqlite::Connection::open(&artifact_path).expect("open artifact mutation"); - connection - .execute( - "UPDATE source_pages SET page_digest = ?1, cumulative_digest = ?2, import_dictionary_digest = ?3 WHERE page_ordinal = 0", - [ - digest_id::('1').as_str(), - digest_id::('2').as_str(), - digest_id::('3').as_str(), - ], - ) - .expect("tamper persisted page chain"); + assert!( + connection + .execute( + "UPDATE source_pages SET page_digest = ?1, cumulative_digest = ?2, import_dictionary_digest = ?3 WHERE page_ordinal = 0", + [ + digest_id::('1').as_str(), + digest_id::('2').as_str(), + digest_id::('3').as_str(), + ], + ) + .is_err(), + "source-page authority is immutable from admission" + ); drop(connection); - assert!(matches!( - builder.append_page(&pages[1], &control), - Err(CodeLexicalArtifactErrorV1::Corrupt(_)) - )); + builder + .append_page(&pages[1], &control) + .expect("append canonical successor after denied tamper"); } #[test] @@ -1639,9 +1811,16 @@ fn disk_artifact_first_finalize_rejects_self_attesting_derived_mutation() { drop(connection); assert!(matches!( - builder.advance_finalization(&source_receipt, 128, &control), - Err(CodeLexicalArtifactErrorV1::Corrupt(_)) + builder + .advance_finalization(&source_receipt, 1, &control) + .expect("persist freeze before authenticated scan"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } )); + let corrupted = builder.advance_finalization(&source_receipt, 4_096, &control); + assert!( + matches!(corrupted, Err(CodeLexicalArtifactErrorV1::Corrupt(_))), + "mutated derived content must fail authenticated finalization: {corrupted:?}" + ); // A corrupted staging file must fail closed. Recovery explicitly restages // trusted pages into a fresh artifact; bounded finalization never replays @@ -1748,33 +1927,34 @@ fn disk_artifact_corruption_is_sticky_across_finalize_and_reopen_retries() { ) .expect("artifact row"); row.push(b' '); - connection - .execute( - "UPDATE rows SET row = ?1 WHERE document_id = (SELECT MIN(document_id) FROM rows)", - [row], - ) - .expect("mutate artifact row without damaging SQLite structure"); + assert!( + connection + .execute( + "UPDATE rows SET row = ?1 WHERE document_id = (SELECT MIN(document_id) FROM rows)", + [row], + ) + .is_err(), + "sealed artifacts deny base-row mutation" + ); let integrity: String = connection .query_row("PRAGMA quick_check(1)", [], |row| row.get(0)) .expect("SQLite integrity check"); assert_eq!(integrity, "ok"); drop(connection); - for _ in 0..2 { - assert!(matches!( - builder.finalize(&source_receipt, &control), - Err(CodeLexicalArtifactErrorV1::Corrupt(_)) - )); - assert!(matches!( - CodeLexicalArtifactReaderV1::open_with_control( - &artifact_path, - &verified, - CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, - &control, - ), - Err(CodeLexicalArtifactErrorV1::Corrupt(_)) - )); - } + assert_eq!( + builder + .finalize(&source_receipt, &control) + .expect("denied mutation preserves sealed receipt"), + verified + ); + CodeLexicalArtifactReaderV1::open_with_control( + &artifact_path, + &verified, + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, + &control, + ) + .expect("denied mutation preserves readable artifact"); } #[test] @@ -2395,10 +2575,15 @@ fn disk_artifact_bounded_work_budget_exhaustion_resumes_activation() { // source or duplicating rows. Their SQLite cursor is deliberately // durable, so retries are not required to preserve the staging file's // byte size. + assert!(matches!( + builder + .advance_finalization(&source_receipt, 1, &control) + .expect("persist immutable finalization freeze"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); for round in 0..4 { - // The fifth checkpoint is the first one after the finalization marker - // is durable. Earlier refusal is intentionally mutation-free and does - // not freeze append admission. + // Every retry starts from the already durable freeze and may yield + // without replaying source or weakening immutable base authority. let exhausted = BudgetExhaustedAtObservation::new(5); let outcome = builder.advance_finalization(&source_receipt, usize::MAX, &exhausted); assert!( From aeae244954530bf15d5ad5e4e63b082e7035dcc3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 02:33:04 +0000 Subject: [PATCH 16/69] fix(query): share repeated union bind values --- .../lexical/projection/artifact/reader.rs | 113 +++++++++++++----- 1 file changed, 80 insertions(+), 33 deletions(-) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs index 1565aa4875..dce97b136a 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs @@ -619,50 +619,90 @@ impl DocumentQueryV1 { /// the only storage used for the set-operation work table. const ARTIFACT_UNION_COMPOUND_ARMS_V1: usize = 64; -fn union_document_queries(queries: impl IntoIterator) -> DocumentQueryV1 { - let mut level = queries +fn union_document_queries( + queries: impl IntoIterator, +) -> Result { + let queries = queries .into_iter() .filter(|query| query.sql.is_some()) .collect::>(); - if level.is_empty() { - return DocumentQueryV1::empty(); + if queries.is_empty() { + return Ok(DocumentQueryV1::empty()); } + + // Keep each compound arm below SQLite's expression limit, while sharing + // equal bind values across arms. A large fuzzy/phrase request commonly + // repeats its encoded field, so retaining one bind slot for every textual + // occurrence needlessly crosses the portable 999-variable ceiling even + // though the request's distinct values remain bounded. Named parameters + // also remain safe when this query is embedded in the frequency probe. + let mut parameters = Vec::new(); + let mut level = queries + .into_iter() + .map(|query| { + let Some(sql) = query.sql else { + return Ok(String::new()); + }; + let sql = rewrite_union_query_parameters(&sql, &query.parameters, &mut parameters)?; + Ok(format!("SELECT document_id FROM ({sql})")) + }) + .collect::, RetrievalPortError>>()? + .into_iter() + .filter(|query| !query.is_empty()) + .collect::>(); while level.len() > 1 { level = level .chunks(ARTIFACT_UNION_COMPOUND_ARMS_V1) - .map(compound_union_document_queries) + .map(|queries| { + let sql = queries.join(" UNION ALL "); + format!("SELECT document_id FROM ({sql})") + }) .collect(); } let Some(root) = level.pop() else { - return DocumentQueryV1::empty(); + return Ok(DocumentQueryV1::empty()); }; - DocumentQueryV1 { - sql: root - .sql - .map(|sql| format!("SELECT DISTINCT document_id FROM ({sql}) ORDER BY document_id")), - parameters: root.parameters, - } + Ok(DocumentQueryV1 { + sql: Some(format!( + "SELECT DISTINCT document_id FROM ({root}) ORDER BY document_id" + )), + parameters, + }) } -fn compound_union_document_queries(queries: &[DocumentQueryV1]) -> DocumentQueryV1 { - let mut sql = String::new(); - let mut parameters = Vec::new(); - for query in queries { - let Some(query_sql) = query.sql.as_deref() else { +fn rewrite_union_query_parameters( + sql: &str, + query_parameters: &[Value], + parameters: &mut Vec, +) -> Result { + let mut rewritten = String::with_capacity(sql.len()); + let mut parameter_ordinal = 0usize; + for character in sql.chars() { + if character != '?' { + rewritten.push(character); continue; - }; - if !sql.is_empty() { - sql.push_str(" UNION ALL "); } - sql.push_str("SELECT document_id FROM ("); - sql.push_str(query_sql); - sql.push(')'); - parameters.extend(query.parameters.iter().cloned()); + let Some(value) = query_parameters.get(parameter_ordinal) else { + return Err(RetrievalPortError::Contract( + "document query SQL has more bind placeholders than values".to_owned(), + )); + }; + let slot = if let Some(slot) = parameters.iter().position(|candidate| candidate == value) { + slot + } else { + parameters.push(value.clone()); + parameters.len() - 1 + }; + rewritten.push_str(":d"); + rewritten.push_str(&slot.to_string()); + parameter_ordinal += 1; } - DocumentQueryV1 { - sql: Some(sql), - parameters, + if parameter_ordinal != query_parameters.len() { + return Err(RetrievalPortError::Contract( + "document query has values without bind placeholders".to_owned(), + )); } + Ok(rewritten) } fn visit_document_ids( @@ -881,7 +921,7 @@ impl<'a> ArtifactQueryV1<'a> { .cloned() .map(|phrase| (phrase, 0usize)) .collect::>(); - let phrase_documents = union_document_queries(phrase_queries.values().cloned()); + let phrase_documents = union_document_queries(phrase_queries.values().cloned())?; visit_lexical_rows( self.connection, &phrase_documents, @@ -1114,7 +1154,7 @@ impl<'a> ArtifactQueryV1<'a> { )); } sources.extend(phrase_queries.values().cloned()); - Ok(union_document_queries(sources)) + union_document_queries(sources) } fn exact_documents( @@ -1144,7 +1184,7 @@ impl<'a> ArtifactQueryV1<'a> { literal.canonical_bytes.clone(), )); } - Ok(union_document_queries(sources)) + union_document_queries(sources) } fn visit_documents( @@ -2325,7 +2365,8 @@ mod tests { DocumentQueryV1::term_except("render".to_owned(), "subtoken".to_owned()), DocumentQueryV1::term_except("renderer".to_owned(), "subtoken".to_owned()), DocumentQueryV1::term("subtoken".to_owned(), "render".to_owned()), - ]); + ]) + .expect("small document union"); assert_eq!(streamed_documents(&connection, &query), vec![1, 2, 3]); assert_eq!( @@ -2334,7 +2375,8 @@ mod tests { &union_document_queries([DocumentQueryV1::term( "subtoken".to_owned(), "render".to_owned(), - )]), + )]) + .expect("single document union"), ), vec![3], "one source query must preserve bitmap-like candidate deduplication" @@ -2367,7 +2409,12 @@ mod tests { }) .collect::>(); - let query = union_document_queries(sources); + let query = union_document_queries(sources).expect("large document union"); + assert_eq!( + query.parameters.len(), + source_count as usize + 1, + "repeated field binds share one bounded SQLite parameter slot" + ); assert_eq!( streamed_documents(&connection, &query), From d40e9be105e6afe87cef2cd26d857ff6ca70b818 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 02:37:52 +0000 Subject: [PATCH 17/69] perf(index): preserve immutable text catch-up --- src/daemon/code_index_scheduler.rs | 213 ++++++++++++------ src/daemon/code_index_scheduler/registry.rs | 91 +++++++- .../registry/resident_memory.rs | 8 +- src/daemon/code_index_scheduler/tests.rs | 117 +++++++++- 4 files changed, 355 insertions(+), 74 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 06a9c50f2d..fc6981524c 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -7,7 +7,7 @@ use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, fs::File, io::{Read, Write}, - num::NonZeroU64, + num::{NonZeroU64, NonZeroUsize}, panic::{AssertUnwindSafe, catch_unwind}, path::{Path, PathBuf}, sync::{ @@ -82,11 +82,10 @@ use crate::{ lexical::{ CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeExactLexicalArtifactReaderV1, - CodeLexicalArtifactBatchLimitV1, CodeLexicalArtifactBuilderV1, - CodeLexicalArtifactErrorV1, CodeLexicalArtifactFinalizationPhaseV1, - CodeLexicalArtifactFinalizationStepV1, CodeLexicalArtifactReaderV1, - CodeLexicalProjectionMetadataV1, LexicalLane, LexicalLaneEvidence, LexicalLaneRequest, - LexicalLaneRetriever, + CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, + CodeLexicalArtifactFinalizationPhaseV1, CodeLexicalArtifactFinalizationStepV1, + CodeLexicalArtifactReaderV1, CodeLexicalProjectionMetadataV1, LexicalLane, + LexicalLaneEvidence, LexicalLaneRequest, LexicalLaneRetriever, }, ports::RetrievalPortError, }, @@ -1541,6 +1540,7 @@ type GenerationServingCachesV1 = ( Arc>, Arc>>, Arc, + GenerationTextControlV1, Arc>, u64, Arc>, @@ -1548,6 +1548,69 @@ type GenerationServingCachesV1 = ( pub(super) type CodeIndexBuildProgressSlotV1 = Arc>; +/// Cancellation authority for derivations of one immutable sealed generation. +/// +/// Worktree freshness epochs deliberately do not participate: a hook wake can +/// make the source worktree newer, but it cannot invalidate bytes already +/// sealed under a content-addressed generation. Only daemon shutdown, owner +/// retirement, or replacement by another serving generation retires this +/// control. +#[derive(Clone)] +struct GenerationTextControlV1 { + execution: DaemonCodeIndexControlV1, + retirement_epoch: Arc, + #[cfg(feature = "hotpath")] + shutting_down: Arc, +} + +#[cfg(feature = "hotpath")] +#[derive(Clone, Copy)] +enum GenerationTextCancellationSourceV1 { + Shutdown, + Superseded, +} + +impl GenerationTextControlV1 { + fn new(shutting_down: Arc) -> Self { + let retirement_epoch = Arc::new(AtomicU64::new(0)); + let execution = DaemonCodeIndexControlV1::new( + Arc::clone(&retirement_epoch), + Arc::clone(&shutting_down), + ); + Self { + execution, + retirement_epoch, + #[cfg(feature = "hotpath")] + shutting_down, + } + } + + fn retire(&self) { + DaemonCodeIndexControlV1::advance(&self.retirement_epoch); + } + + #[cfg(feature = "hotpath")] + fn cancellation_source(&self) -> Option { + if self.shutting_down.load(Ordering::Acquire) { + Some(GenerationTextCancellationSourceV1::Shutdown) + } else if self.execution.is_cancelled() { + Some(GenerationTextCancellationSourceV1::Superseded) + } else { + None + } + } +} + +impl CodeIndexExecutionControlV1 for GenerationTextControlV1 { + fn is_cancelled(&self) -> bool { + self.execution.is_cancelled() + } + + fn is_deadline_exceeded(&self) -> bool { + self.execution.is_deadline_exceeded() + } +} + #[derive(Default)] pub(super) struct CodeIndexBuildProgressSlotStateV1 { generation_id: Option, @@ -1702,19 +1765,18 @@ pub(in crate::daemon) struct LatestCompleteCodeIndexV1 { /// are installed. text_projection_build: Arc>>, text_projection_failed: Arc, + text_control: GenerationTextControlV1, text_progress_state: Arc>, text_progress_slot: CodeIndexBuildProgressSlotV1, text_progress_owner_epoch: u64, - /// Existing durable daemon-authority epoch. It orders progress across - /// process restarts; the slot epoch orders only within this incarnation. + /// Durable daemon-authority epoch, shared by all scheduler owners created + /// during one daemon invocation. + text_progress_daemon_incarnation: u64, + /// Registry-minted scheduler-owner epoch. It orders progress across + /// scheduler owner replacements within one daemon incarnation. text_progress_producer_incarnation: u64, /// The durable text-artifact store for this generation's store root. text_artifact_store: DaemonCodeTextArtifactStoreV1, - /// Exact scheduler authorities for shutdown and superseding source epochs. - /// Each bounded pass captures the then-current epoch before touching the - /// durable source or artifact. - text_control_epoch: Arc, - text_control_shutdown: Arc, graph_activation: Arc>, } @@ -2529,6 +2591,7 @@ impl LatestCompleteCodeIndexV1 { state.rates_and_eta(total_lexical_bytes); let snapshot = CodeIndexBuildProgressV1 { generation_id: self.generation.manifest().generation_id.as_str().to_owned(), + daemon_incarnation: self.text_progress_daemon_incarnation, producer_incarnation: self.text_progress_producer_incarnation, progress_epoch: 0, sealed_source_digest: build.sealed_identity.digest.as_str().to_owned(), @@ -2574,6 +2637,7 @@ impl LatestCompleteCodeIndexV1 { .elapsed_micros(); Ok(CodeIndexBuildProgressV1 { generation_id: generation_id.as_str().to_owned(), + daemon_incarnation: self.text_progress_daemon_incarnation, producer_incarnation: self.text_progress_producer_incarnation, progress_epoch: 0, sealed_source_digest: sealed_identity.digest.as_str().to_owned(), @@ -2674,6 +2738,20 @@ impl LatestCompleteCodeIndexV1 { /// concurrent scheduler wakes. fn advance_text_serving(&self, maximum_work: usize) -> Result { let result = self.advance_text_serving_inner(maximum_work); + if matches!(&result, Err(RetrievalPortError::Cancelled)) { + #[cfg(feature = "hotpath")] + match self.text_control.cancellation_source() { + Some(GenerationTextCancellationSourceV1::Shutdown) => { + hotpath::gauge!("query.artifact.cancelled.shutdown_total").inc(1_u64); + } + Some(GenerationTextCancellationSourceV1::Superseded) => { + hotpath::gauge!("query.artifact.cancelled.superseded_total").inc(1_u64); + } + None => { + hotpath::gauge!("query.artifact.cancelled.external_total").inc(1_u64); + } + } + } if result.as_ref().is_err_and(|error| { matches!( error, @@ -2692,10 +2770,7 @@ impl LatestCompleteCodeIndexV1 { if self.query_owners.get().is_some() { return Ok(true); } - let control = DaemonCodeIndexControlV1::new( - Arc::clone(&self.text_control_epoch), - Arc::clone(&self.text_control_shutdown), - ); + let control = self.text_execution_control(); let mut build = self .text_projection_build .lock() @@ -2706,6 +2781,10 @@ impl LatestCompleteCodeIndexV1 { self.advance_artifact_text_serving(&mut build, maximum_work, &control) } + fn text_execution_control(&self) -> GenerationTextControlV1 { + self.text_control.clone() + } + /// The durable-artifact journey: reopen a published head when one exists, /// otherwise stream the sealed generation through the staging builder one /// bounded page window at a time, finalize, publish, and reopen. @@ -2843,13 +2922,11 @@ impl LatestCompleteCodeIndexV1 { ) })?; let mut remaining = maximum_work.min(TEXT_ARTIFACT_MAXIMUM_WORK_PER_ADVANCE_V1); - let mut maximum_batch_pages = remaining.min(TEXT_ARTIFACT_BATCH_PAGES_V1).max(1); - let mut maximum_batch_bytes = TEXT_ARTIFACT_BATCH_BYTES_V1; while remaining > 0 && artifact_build.source_receipt.is_none() { - maximum_batch_pages = maximum_batch_pages.min(remaining).max(1); + let maximum_batch_pages = remaining.min(TEXT_ARTIFACT_BATCH_PAGES_V1).max(1); let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new( maximum_batch_pages, - maximum_batch_bytes, + TEXT_ARTIFACT_BATCH_BYTES_V1, ) .map_err(map_sealed_page_source_error)?; #[cfg(feature = "hotpath")] @@ -2860,14 +2937,24 @@ impl LatestCompleteCodeIndexV1 { self.publish_text_progress_phase(CodeIndexBuildPhaseV1::SourceScan, 0, 0); let mut durable_progress = None; let mut commit_latency_micros = None; - let mut attempted_page_retained_bytes = Vec::new(); let admitted = { let (source, builder) = (&mut artifact_build.source, &mut artifact_build.builder); source.next_page_batch_if(control, bounds, |pages| { - attempted_page_retained_bytes = pages - .iter() - .map(|page| page.retained_owned_bytes()) - .collect(); + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.batch.offered_pages_total") + .inc(u64::try_from(pages.len()).unwrap_or(u64::MAX)); + let accepted = + NonZeroUsize::new(builder.largest_admissible_page_prefix(pages)?) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Unreserved( + "one sealed lexical page exceeds the artifact build memory budget" + .to_owned(), + ) + })?; + let pages = &pages[..accepted.get()]; + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.batch.accepted_pages_total") + .inc(u64::try_from(pages.len()).unwrap_or(u64::MAX)); let batch_pages = u64::try_from(pages.len()).map_err(|_| { CodeLexicalArtifactErrorV1::Contract( "text-artifact batch page count exceeds u64".to_owned(), @@ -2900,7 +2987,7 @@ impl LatestCompleteCodeIndexV1 { Ok::<_, CodeLexicalArtifactErrorV1>(progress) })?; durable_progress = Some(progress); - Ok(()) + Ok(accepted) }) }; let admitted = match admitted { @@ -2908,38 +2995,7 @@ impl LatestCompleteCodeIndexV1 { Ok(Err(error @ CodeLexicalArtifactErrorV1::BatchTooLarge { .. })) => { #[cfg(feature = "hotpath")] hotpath::gauge!("query.artifact.batch.refusal_total").inc(1u64); - if attempted_page_retained_bytes.len() <= 1 { - if matches!( - &error, - CodeLexicalArtifactErrorV1::BatchTooLarge { - limit: CodeLexicalArtifactBatchLimitV1::Memory, - .. - } - ) { - self.publish_text_progress_blocked( - CodeIndexBuildBlockedReasonV1::ResidentMemory, - ); - } - return Err(map_text_artifact_error(error)); - } - maximum_batch_pages = attempted_page_retained_bytes.len() / 2; - #[cfg(feature = "hotpath")] - hotpath::gauge!("query.artifact.batch.shrink_total").inc(1u64); - maximum_batch_bytes = maximum_batch_pages - .checked_mul(std::mem::size_of::< - crate::code_index::production::VerifiedSealedLexicalPageV1, - >()) - .and_then(|slots| { - attempted_page_retained_bytes[..maximum_batch_pages] - .iter() - .try_fold(slots, |total, bytes| total.checked_add(*bytes)) - }) - .ok_or_else(|| { - RetrievalPortError::Contract( - "reduced text-artifact batch retained bytes overflowed".to_owned(), - ) - })?; - continue; + return Err(map_text_artifact_error(error)); } Ok(Err(error @ CodeLexicalArtifactErrorV1::Unreserved(_))) => { self.publish_text_progress_blocked( @@ -3003,7 +3059,6 @@ impl LatestCompleteCodeIndexV1 { "accepted text-artifact batch exceeded its work budget".to_owned(), ) })?; - maximum_batch_bytes = TEXT_ARTIFACT_BATCH_BYTES_V1; } VerifiedSealedLexicalPageBatchReadV1::Complete(receipt) => { artifact_build.source_receipt = Some(receipt); @@ -3362,6 +3417,9 @@ pub(super) struct CodeIndexWorktreeSchedulerV1 { /// slot at mount so dashboard reads never acquire the scheduler mutex. build_progress: CodeIndexBuildProgressSlotV1, /// Durable daemon-authority epoch bound by the process registry at mount. + progress_daemon_incarnation: u64, + /// Registry-minted scheduler-owner token. A same-daemon retire/remount gets + /// a strictly newer token so delayed progress cannot outrank the new owner. progress_producer_incarnation: u64, /// Optional semantic hook: schedule `FastEmbed` projection without joining it. semantic_schedule: @@ -3467,6 +3525,7 @@ impl CodeIndexWorktreeSchedulerV1 { ignored_source_admissions: Vec::new(), query_owners: Mutex::new(None), build_progress: Arc::new(RwLock::new(CodeIndexBuildProgressSlotStateV1::default())), + progress_daemon_incarnation: 1, progress_producer_incarnation: 1, semantic_schedule: None, }; @@ -3484,10 +3543,23 @@ impl CodeIndexWorktreeSchedulerV1 { self.resident_memory = resident_memory; } - pub(super) fn bind_progress_producer_incarnation(&mut self, producer_incarnation: u64) { + pub(super) fn bind_progress_incarnations( + &mut self, + daemon_incarnation: u64, + producer_incarnation: u64, + ) { + self.progress_daemon_incarnation = daemon_incarnation.max(1); self.progress_producer_incarnation = producer_incarnation.max(1); } + #[cfg(test)] + pub(super) const fn progress_incarnations_for_test(&self) -> (u64, u64) { + ( + self.progress_daemon_incarnation, + self.progress_producer_incarnation, + ) + } + pub(super) fn build_progress_slot(&self) -> CodeIndexBuildProgressSlotV1 { Arc::clone(&self.build_progress) } @@ -4283,6 +4355,7 @@ impl CodeIndexWorktreeSchedulerV1 { record_index, text_projection_build, text_projection_failed, + text_control, text_progress_state, text_progress_owner_epoch, graph_activation, @@ -4293,6 +4366,7 @@ impl CodeIndexWorktreeSchedulerV1 { index, build, failed, + control, progress, progress_epoch, interactive, @@ -4301,15 +4375,20 @@ impl CodeIndexWorktreeSchedulerV1 { Arc::clone(index), Arc::clone(build), Arc::clone(failed), + control.clone(), Arc::clone(progress), *progress_epoch, Arc::clone(interactive), ), _ => { + if let Some((_, _, _, _, _, control, _, _, _)) = cached.as_ref() { + control.retire(); + } let owners = Arc::new(OnceLock::new()); let index = Arc::new(OnceLock::new()); let build = Arc::new(Mutex::new(None)); let failed = Arc::new(AtomicBool::new(false)); + let control = GenerationTextControlV1::new(Arc::clone(&self.shutting_down)); let progress = Arc::new(Mutex::new(CodeIndexBuildProgressStateV1::new())); let graph_activation = Arc::new(RwLock::new(CodeGraphActivationStateV1::Pending)); let progress_epoch = hotpath::measure_block!("query.artifact.progress.publish", { @@ -4324,6 +4403,7 @@ impl CodeIndexWorktreeSchedulerV1 { Arc::clone(&index), Arc::clone(&build), Arc::clone(&failed), + control.clone(), Arc::clone(&progress), progress_epoch, Arc::clone(&graph_activation), @@ -4333,6 +4413,7 @@ impl CodeIndexWorktreeSchedulerV1 { index, build, failed, + control, progress, progress_epoch, graph_activation, @@ -4345,9 +4426,11 @@ impl CodeIndexWorktreeSchedulerV1 { record_index, text_projection_build, text_projection_failed, + text_control, text_progress_state, text_progress_slot: Arc::clone(&self.build_progress), text_progress_owner_epoch, + text_progress_daemon_incarnation: self.progress_daemon_incarnation, text_progress_producer_incarnation: self.progress_producer_incarnation, text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( &self.store_root, @@ -4356,8 +4439,6 @@ impl CodeIndexWorktreeSchedulerV1 { &self.project_id, &self.worktree_id, ), - text_control_epoch: Arc::clone(&self.epoch), - text_control_shutdown: Arc::clone(&self.shutting_down), graph_activation, } } @@ -4415,11 +4496,15 @@ impl CodeIndexWorktreeSchedulerV1 { record_index: Arc::new(OnceLock::new()), text_projection_build: Arc::new(Mutex::new(None)), text_projection_failed: Arc::new(AtomicBool::new(false)), + text_control: GenerationTextControlV1::new(Arc::clone( + &self.shutting_down, + )), text_progress_state: Arc::new(Mutex::new( CodeIndexBuildProgressStateV1::new(), )), text_progress_slot: Arc::new(RwLock::new(progress_slot)), text_progress_owner_epoch, + text_progress_daemon_incarnation: self.progress_daemon_incarnation, text_progress_producer_incarnation: self.progress_producer_incarnation, text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( &self.store_root, @@ -4428,8 +4513,6 @@ impl CodeIndexWorktreeSchedulerV1 { &self.project_id, &self.worktree_id, ), - text_control_epoch: Arc::clone(&self.epoch), - text_control_shutdown: Arc::clone(&self.shutting_down), graph_activation: Arc::new(RwLock::new( CodeGraphActivationStateV1::Pending, )), diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index c8c234bae6..45eadfdd39 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -574,6 +574,14 @@ impl Default for PendingWakeV1 { } impl PendingWakeV1 { + fn has_pending_arrival(&self) -> bool { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .micros + != 0 + } + #[cfg(test)] fn note_foreign_wake_attempt_for_test(&self) { let gate = self @@ -696,7 +704,11 @@ pub(crate) struct CodeIndexSchedulerRegistryV1 { pub(super) max_worktrees: usize, /// Durable daemon-authority epoch shared by every progress producer in /// this registry. This is never derived from wall-clock time. - pub(super) progress_producer_incarnation: u64, + pub(super) progress_daemon_incarnation: u64, + /// Next scheduler-owner token within `progress_daemon_incarnation`. + /// Cloned registries share this authority, so same-daemon retire/remounts + /// cannot reuse a progress ordering key. + pub(super) next_progress_producer_incarnation: Arc, pub(super) resident_memory: Arc, pub(super) byte_pool: Arc, pub(super) mounted: Arc>>, @@ -726,6 +738,22 @@ pub(crate) struct CodeIndexSchedulerRegistryV1 { } impl CodeIndexSchedulerRegistryV1 { + fn incomplete_text_slice_may_continue(pending_wake: &PendingWakeV1) -> bool { + !pending_wake.has_pending_arrival() + } + + fn mint_progress_producer_incarnation(&self) -> Result { + self.next_progress_producer_incarnation + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current.checked_add(1) + }) + .map_err(|_| { + CodeIndexSchedulerErrorV1::Identity( + "code-index progress producer incarnation authority is exhausted".to_owned(), + ) + }) + } + #[hotpath::measure] pub(in crate::daemon) fn register_activation( &self, @@ -1928,6 +1956,7 @@ impl CodeIndexSchedulerRegistryV1 { "code-index scheduler capacity is zero".to_owned(), )); } + let producer_incarnation = self.mint_progress_producer_incarnation()?; CodeIndexWorktreeSchedulerV1::open( project_id, project_root, @@ -1936,7 +1965,8 @@ impl CodeIndexSchedulerRegistryV1 { ) .map(|mut scheduler| { scheduler.bind_resident_memory(Arc::clone(&self.resident_memory)); - scheduler.bind_progress_producer_incarnation(self.progress_producer_incarnation); + scheduler + .bind_progress_incarnations(self.progress_daemon_incarnation, producer_incarnation); scheduler }) } @@ -2168,7 +2198,8 @@ impl CodeIndexSchedulerRegistryV1 { let open_byte_pool = Arc::clone(&self.byte_pool); let open_semantic_schedule = semantic_schedule.clone(); let open_resident_memory = Arc::clone(&self.resident_memory); - let progress_producer_incarnation = self.progress_producer_incarnation; + let progress_daemon_incarnation = self.progress_daemon_incarnation; + let progress_producer_incarnation = self.mint_progress_producer_incarnation()?; let (opened, cold_mount_reservation) = tokio::task::spawn_blocking(move || { #[cfg(test)] Self::pause_cold_mount_open_for_test(&open_project_root); @@ -2183,7 +2214,10 @@ impl CodeIndexSchedulerRegistryV1 { let mut opened = opened?; opened.replace_semantic_schedule_hook(open_semantic_schedule); opened.bind_resident_memory(open_resident_memory); - opened.bind_progress_producer_incarnation(progress_producer_incarnation); + opened.bind_progress_incarnations( + progress_daemon_incarnation, + progress_producer_incarnation, + ); Ok::<_, CodeIndexSchedulerErrorV1>((opened, cold_mount_reservation)) }) .await @@ -2326,6 +2360,7 @@ impl CodeIndexSchedulerRegistryV1 { .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + let mut text_slice_incomplete = false; if let Some(latest) = text_generation && latest.text_serving_needs_work() { @@ -2338,7 +2373,7 @@ impl CodeIndexSchedulerRegistryV1 { Ok(Ok(true)) => {} Ok(Ok(false)) => { worker_wake.notify_one(); - continue; + text_slice_incomplete = true; } Ok(Err(error)) => { tracing::warn!( @@ -2357,6 +2392,18 @@ impl CodeIndexSchedulerRegistryV1 { } } } + if worker_shutting_down.load(Ordering::Acquire) { + return; + } + if text_slice_incomplete { + if Self::incomplete_text_slice_may_continue(&worker_pending_wake) { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.slice.continue_total").inc(1_u64); + continue; + } + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.slice.yield_to_reconcile_total").inc(1_u64); + } // Admission is held: queue wait ends and service time begins. let started_micros = now_micros().0; let (arrival, trigger) = Self::take_pending_arrival( @@ -4045,6 +4092,40 @@ impl CodeIndexSchedulerRegistryV1 { } } +#[cfg(test)] +mod text_slice_fairness_tests { + use super::{CodeIndexCadenceTriggerV1, CodeIndexSchedulerRegistryV1, PendingWakeV1}; + + #[test] + fn pending_reconcile_is_serviced_between_bounded_text_slices() { + let pending = PendingWakeV1::default(); + let wake = tokio::sync::Notify::new(); + assert!( + CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), + "a text-only self-wake may advance the next bounded slice" + ); + + CodeIndexSchedulerRegistryV1::note_wake( + &pending, + &wake, + CodeIndexCadenceTriggerV1::HookHint, + ); + assert!( + !CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), + "a pending source reconcile must win before another text slice" + ); + + let _ = CodeIndexSchedulerRegistryV1::take_pending_arrival( + &pending, + CodeIndexCadenceTriggerV1::Mount, + ); + assert!( + CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), + "text continuation resumes only after reconcile claims the pending arrival" + ); + } +} + impl tracedecay_usecases::feedback::cycle_production::ProductionFeedbackDocumentIdentityPort for CodeIndexSchedulerRegistryV1 { diff --git a/src/daemon/code_index_scheduler/registry/resident_memory.rs b/src/daemon/code_index_scheduler/registry/resident_memory.rs index ef1310a178..561771e917 100644 --- a/src/daemon/code_index_scheduler/registry/resident_memory.rs +++ b/src/daemon/code_index_scheduler/registry/resident_memory.rs @@ -32,16 +32,20 @@ impl CodeIndexSchedulerRegistryV1 { ) } + /// Build a registry under one durable daemon epoch. The constructor name + /// is retained for its invocation-state caller; individual producer + /// incarnations are minted below this daemon authority for each scheduler. pub fn with_resident_memory_and_progress_producer_incarnation( max_worktrees: usize, resident_memory: Arc, - progress_producer_incarnation: u64, + progress_daemon_incarnation: u64, ) -> Self { let (generation_publications, _) = tokio::sync::broadcast::channel(super::GENERATION_PUBLICATION_CHANNEL_CAPACITY); Self { max_worktrees, - progress_producer_incarnation: progress_producer_incarnation.max(1), + progress_daemon_incarnation: progress_daemon_incarnation.max(1), + next_progress_producer_incarnation: Arc::new(std::sync::atomic::AtomicU64::new(1)), resident_memory, byte_pool: Arc::new(super::SharedCodeIndexBytePoolV1::default()), mounted: Arc::new(tokio::sync::Mutex::new(std::collections::BTreeMap::new())), diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 7d7edc456d..61dc3c5543 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -218,6 +218,7 @@ fn progress_snapshot_for_generation( ) -> crate::dashboard::code_index_freshness_api::CodeIndexBuildProgressV1 { crate::dashboard::code_index_freshness_api::CodeIndexBuildProgressV1 { generation_id: generation_id.as_str().to_owned(), + daemon_incarnation: 1, producer_incarnation: 1, progress_epoch: 0, sealed_source_digest: format!("sha256:{}", "a".repeat(64)), @@ -3852,6 +3853,105 @@ fn ordinary_background_reconcile_does_not_supersede_in_flight_text_work() { ); } +#[test] +fn same_daemon_scheduler_retire_remount_mints_new_progress_producer() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn producer_epoch() {}\n")]); + let store = TempDir::new().expect("store root"); + let registry = CodeIndexSchedulerRegistryV1::new(1); + let first = registry + .open_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + ) + .expect("first scheduler owner"); + let (first_daemon, first_producer) = first.progress_incarnations_for_test(); + drop(first); + let second = registry + .open_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + ) + .expect("replacement scheduler owner"); + let (second_daemon, second_producer) = second.progress_incarnations_for_test(); + + assert_eq!(second_daemon, first_daemon); + assert!( + second_producer > first_producer, + "same-daemon scheduler replacement must outrank delayed low-epoch progress" + ); +} + +#[test] +fn source_epoch_advance_does_not_discard_immutable_text_progress() { + let mut source = String::new(); + for ordinal in 0..600 { + writeln!( + source, + "pub fn immutable_symbol_{ordinal}() -> usize {{ {ordinal} }}" + ) + .expect("write immutable source fixture"); + } + let fixture = GitFixture::new(&[("src/lib.rs", source.as_str())]); + let store = TempDir::new().expect("store root"); + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("publish generation")); + let latest = scheduler.latest_complete().expect("latest generation"); + let admitted = latest.text_execution_control(); + + // Reproduce a hook arriving after a text pass captures its control but + // before the sealed source's first cancellation checkpoint. + scheduler.notify_path(fixture.path().join("src/lib.rs")); + let result = { + let mut build = latest + .text_projection_build + .lock() + .expect("generation text projection"); + latest.advance_artifact_text_serving(&mut build, 1, &admitted) + }; + + assert!( + matches!(result, Ok(false) | Ok(true)), + "a worktree freshness epoch must not cancel immutable generation work: {result:?}" + ); + assert!( + latest + .text_projection_build + .lock() + .expect("retained text projection") + .is_some() + || latest.query_owners_are_warm(), + "the bounded pass must retain or complete its generation-owned progress" + ); +} + +#[test] +fn shutdown_cancels_generation_owned_text_work() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn shutdown_text_work() {}\n")]); + let store = TempDir::new().expect("store root"); + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("publish generation")); + let latest = scheduler.latest_complete().expect("latest generation"); + scheduler + .shutting_down + .store(true, std::sync::atomic::Ordering::Release); + + assert_eq!( + latest.advance_text_serving(1), + Err(tracedecay_query::retrieval::RetrievalPortError::Cancelled), + "daemon shutdown must remain a cancellation fence for immutable text work" + ); +} + #[test] fn generation_replacement_drops_incomplete_text_projection_state() { let fixture = GitFixture::new(&[ @@ -3867,6 +3967,7 @@ fn generation_replacement_drops_incomplete_text_projection_state() { ); published(scheduler.reconcile_now().expect("publish first generation")); let original = scheduler.latest_complete().expect("original generation"); + let original_control = original.text_execution_control(); assert!( !original .advance_text_serving(1) @@ -3887,6 +3988,18 @@ fn generation_replacement_drops_incomplete_text_projection_state() { replacement.generation().manifest().generation_id, original_generation ); + let superseded_result = { + let mut build = original + .text_projection_build + .lock() + .expect("superseded projection state"); + original.advance_artifact_text_serving(&mut build, 1, &original_control) + }; + assert_eq!( + superseded_result, + Err(tracedecay_query::retrieval::RetrievalPortError::Cancelled), + "a replacement serving generation must retire the prior text owner" + ); assert!(!Arc::ptr_eq( &original.text_projection_build, &replacement.text_projection_build @@ -3936,8 +4049,8 @@ fn generation_replacement_drops_incomplete_text_projection_state() { .text_projection_build .lock() .expect("replacement projection state") - .is_none(), - "a replacement generation starts with independent empty projection state" + .is_some(), + "publishing the replacement baseline initializes only its independent projection state" ); } From add3b50cf1de580e38d92c0065379e678ebb1b37 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 02:38:46 +0000 Subject: [PATCH 18/69] perf(index): accelerate sealed layout scanning --- .../src/production/lexical_page_source.rs | 413 ++++++++++++++++-- 1 file changed, 376 insertions(+), 37 deletions(-) diff --git a/crates/tracedecay-code-index/src/production/lexical_page_source.rs b/crates/tracedecay-code-index/src/production/lexical_page_source.rs index edf227177d..3a244e1ae7 100644 --- a/crates/tracedecay-code-index/src/production/lexical_page_source.rs +++ b/crates/tracedecay-code-index/src/production/lexical_page_source.rs @@ -19,6 +19,7 @@ const SOURCE_CHAIN_RECORD_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-source-cha const IMPORT_DICTIONARY_CHAIN_RECORD_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-import-dictionary-chain.v1\0"; const CURSOR_DIGEST_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-cursor.v1\0"; +const LAYOUT_PROGRESS_INTERVAL_BYTES: u64 = 16 * 1024 * 1024; type PersistedSealedLexicalCursorFields = ( String, @@ -1561,8 +1562,13 @@ struct SealedLexicalLayoutV1 { first_file_offset: u64, files_end_offset: u64, maximum_file_bytes: u64, + #[cfg(test)] + structural_byte_visits: u64, + #[cfg(test)] + temporary_string_allocations: u64, } +#[hotpath::measure] fn scan_layout( reader: &mut R, admitted_len: u64, @@ -1577,6 +1583,9 @@ fn scan_layout( reader.seek(SeekFrom::Start(0)).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!("sealed lexical source seek failed: {error}")) })?; + hotpath::gauge!("code_index_lexical_layout_scan_attempts").inc(1); + hotpath::gauge!("code_index_lexical_layout_bytes_total").set(admitted_len); + hotpath::gauge!("code_index_lexical_layout_bytes_scanned").set(0); let mut scanner = LayoutScanner::default(); let mut file_hasher = expected_file_digest.map(|_| Sha256::new()); let read_limit = admitted_len.checked_add(1).ok_or_else(|| { @@ -1584,6 +1593,7 @@ fn scan_layout( })?; let mut remaining = read_limit; let mut observed = 0u64; + let mut next_progress = LAYOUT_PROGRESS_INTERVAL_BYTES; let mut buffer = [0u8; 64 * 1024]; while remaining > 0 { checkpoint(control)?; @@ -1624,6 +1634,11 @@ fn scan_layout( "sealed lexical source length overflowed".to_owned(), ) })?; + let admitted_observed = observed.min(admitted_len); + if admitted_observed >= next_progress || admitted_observed == admitted_len { + hotpath::gauge!("code_index_lexical_layout_bytes_scanned").set(admitted_observed); + next_progress = admitted_observed.saturating_add(LAYOUT_PROGRESS_INTERVAL_BYTES); + } remaining -= read_bytes; } if observed != admitted_len { @@ -1641,16 +1656,36 @@ fn scan_layout( scanner.finish() } -#[derive(Default)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LayoutKey { + StateDigest, + Generation, + Files, + FormatRevision, +} + +impl LayoutKey { + fn from_bytes(bytes: &[u8]) -> Option { + match bytes { + b"state_digest" => Some(Self::StateDigest), + b"generation" => Some(Self::Generation), + b"files" => Some(Self::Files), + b"format_revision" => Some(Self::FormatRevision), + _ => None, + } + } +} + struct LayoutScanner { brace_depth: usize, bracket_depth: usize, in_string: bool, escaped: bool, - string: Vec, + string: [u8; 128], + string_len: usize, string_overflowed: bool, - completed_string: Option, - pending_key: Option, + completed_key: Option, + pending_key: Option, capture_state_digest: bool, state_digest: Option, format_revision: Option, @@ -1663,6 +1698,42 @@ struct LayoutScanner { files_end_offset: Option, file_count: u64, maximum_file_bytes: u64, + #[cfg(test)] + structural_byte_visits: u64, + #[cfg(test)] + temporary_string_allocations: u64, +} + +impl Default for LayoutScanner { + fn default() -> Self { + Self { + brace_depth: 0, + bracket_depth: 0, + in_string: false, + escaped: false, + string: [0; 128], + string_len: 0, + string_overflowed: false, + completed_key: None, + pending_key: None, + capture_state_digest: false, + state_digest: None, + format_revision: None, + generation_depth: None, + generation_hasher: None, + generation_digest: None, + files_depth: None, + current_file_start: None, + first_file_offset: None, + files_end_offset: None, + file_count: 0, + maximum_file_bytes: 0, + #[cfg(test)] + structural_byte_visits: 0, + #[cfg(test)] + temporary_string_allocations: 0, + } + } } /// Transition of the generation-payload hash span produced by one observed @@ -1685,7 +1756,19 @@ impl LayoutScanner { base_offset: u64, ) -> Result<(), CodeIndexProductionErrorV1> { let mut active_from = self.generation_hasher.is_some().then_some(0usize); - for (index, &byte) in bytes.iter().enumerate() { + let mut index = 0usize; + while index < bytes.len() { + if self.in_string && !self.escaped { + let relative_end = first_json_string_control(&bytes[index..]); + let end = relative_end.map_or(bytes.len(), |relative| index + relative); + if end > index { + self.observe_string_run(&bytes[index..end]); + index = end; + if index == bytes.len() { + break; + } + } + } let offset = u64::try_from(index) .ok() .and_then(|index| base_offset.checked_add(index)) @@ -1694,7 +1777,7 @@ impl LayoutScanner { "sealed lexical source length overflowed".to_owned(), ) })?; - match self.observe(byte, offset)? { + match self.observe(bytes[index], offset)? { GenerationSpanEvent::None => {} GenerationSpanEvent::Opened => active_from = Some(index), GenerationSpanEvent::Closed => { @@ -1712,6 +1795,7 @@ impl LayoutScanner { self.generation_digest = Some(digest_hasher(hasher)?); } } + index += 1; } if let Some(hasher) = self.generation_hasher.as_mut() { let start = active_from.ok_or_else(|| { @@ -1724,19 +1808,46 @@ impl LayoutScanner { Ok(()) } + /// Consume bytes that cannot alter JSON string state in one bounded step. + /// Only a key or the envelope state digest is retained, and both are + /// capped at the scanner's existing 128-byte contract. + fn observe_string_run(&mut self, bytes: &[u8]) { + #[cfg(test)] + { + self.structural_byte_visits = self.structural_byte_visits.saturating_add(1); + } + let remaining = self.string.len().saturating_sub(self.string_len); + let retained = remaining.min(bytes.len()); + let retained_end = self.string_len + retained; + self.string[self.string_len..retained_end].copy_from_slice(&bytes[..retained]); + self.string_len = retained_end; + if retained < bytes.len() { + self.string_overflowed = true; + } + } + + fn observe_string_byte(&mut self, byte: u8) { + if self.string_len < self.string.len() { + self.string[self.string_len] = byte; + self.string_len += 1; + } else { + self.string_overflowed = true; + } + } + fn observe( &mut self, byte: u8, offset: u64, ) -> Result { + #[cfg(test)] + { + self.structural_byte_visits = self.structural_byte_visits.saturating_add(1); + } if self.in_string { if self.escaped { self.escaped = false; - if self.string.len() < 128 { - self.string.push(byte); - } else { - self.string_overflowed = true; - } + self.observe_string_byte(byte); return Ok(GenerationSpanEvent::None); } match byte { @@ -1744,8 +1855,13 @@ impl LayoutScanner { b'"' => { self.in_string = false; if self.capture_state_digest { - let value = - String::from_utf8(std::mem::take(&mut self.string)).map_err(|_| { + #[cfg(test)] + { + self.temporary_string_allocations = + self.temporary_string_allocations.saturating_add(1); + } + let value = String::from_utf8(self.string[..self.string_len].to_vec()) + .map_err(|_| { CodeIndexProductionErrorV1::Contract( "sealed generation state digest is not UTF-8".to_owned(), ) @@ -1756,26 +1872,19 @@ impl LayoutScanner { self.capture_state_digest = false; self.pending_key = None; } else if !self.string_overflowed { - self.completed_string = Some( - String::from_utf8(std::mem::take(&mut self.string)).map_err(|_| { - CodeIndexProductionErrorV1::Contract( - "sealed generation key is not UTF-8".to_owned(), - ) - })?, - ); + std::str::from_utf8(&self.string[..self.string_len]).map_err(|_| { + CodeIndexProductionErrorV1::Contract( + "sealed generation key is not UTF-8".to_owned(), + ) + })?; + self.completed_key = LayoutKey::from_bytes(&self.string[..self.string_len]); } else { - self.string.clear(); - self.completed_string = None; + self.completed_key = None; } + self.string_len = 0; self.string_overflowed = false; } - _ => { - if self.string.len() < 128 { - self.string.push(byte); - } else { - self.string_overflowed = true; - } - } + _ => self.observe_string_byte(byte), } return Ok(GenerationSpanEvent::None); } @@ -1784,14 +1893,14 @@ impl LayoutScanner { match byte { b'"' => { self.in_string = true; - self.string.clear(); + self.string_len = 0; self.string_overflowed = false; self.capture_state_digest = - self.pending_key.as_deref() == Some("state_digest") && self.brace_depth == 1; + self.pending_key == Some(LayoutKey::StateDigest) && self.brace_depth == 1; } - b':' => self.pending_key = self.completed_string.take(), + b':' => self.pending_key = self.completed_key.take(), b'{' => { - if self.pending_key.as_deref() == Some("generation") && self.brace_depth == 1 { + if self.pending_key == Some(LayoutKey::Generation) && self.brace_depth == 1 { self.generation_depth = Some(self.brace_depth + 1); self.generation_hasher = Some(Sha256::new()); event = GenerationSpanEvent::Opened; @@ -1841,7 +1950,7 @@ impl LayoutScanner { self.pending_key = None; } b'[' => { - if self.pending_key.as_deref() == Some("files") + if self.pending_key == Some(LayoutKey::Files) && self.generation_depth == Some(self.brace_depth) { self.files_depth = Some(self.bracket_depth + 1); @@ -1862,18 +1971,18 @@ impl LayoutScanner { self.pending_key = None; } b'0'..=b'9' - if self.pending_key.as_deref() == Some("format_revision") + if self.pending_key == Some(LayoutKey::FormatRevision) && self.generation_depth == Some(self.brace_depth) => { self.format_revision = Some(u32::from(byte - b'0')); self.pending_key = None; } b',' => { - self.completed_string = None; + self.completed_key = None; self.pending_key = None; } byte if byte.is_ascii_whitespace() => {} - _ => self.completed_string = None, + _ => self.completed_key = None, } Ok(event) } @@ -1928,10 +2037,48 @@ impl LayoutScanner { first_file_offset, files_end_offset, maximum_file_bytes: self.maximum_file_bytes, + #[cfg(test)] + structural_byte_visits: self.structural_byte_visits, + #[cfg(test)] + temporary_string_allocations: self.temporary_string_allocations, }) } } +/// Locate the next quote or escape marker with eight-byte candidate probes. +/// Every input byte is still authenticated by the outer SHA-256 stream; this +/// helper only avoids interpreting ordinary string payload bytes one by one. +fn first_json_string_control(bytes: &[u8]) -> Option { + const LOW_BITS: u64 = 0x0101_0101_0101_0101; + const HIGH_BITS: u64 = 0x8080_8080_8080_8080; + const QUOTES: u64 = u64::from_ne_bytes([b'"'; 8]); + const ESCAPES: u64 = u64::from_ne_bytes([b'\\'; 8]); + + fn contains_zero_byte(value: u64) -> bool { + value.wrapping_sub(LOW_BITS) & !value & HIGH_BITS != 0 + } + + let mut chunks = bytes.chunks_exact(8); + for (chunk_index, chunk) in chunks.by_ref().enumerate() { + let word = u64::from_ne_bytes([ + chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], chunk[7], + ]); + if contains_zero_byte(word ^ QUOTES) || contains_zero_byte(word ^ ESCAPES) { + let base = chunk_index * 8; + return chunk + .iter() + .position(|byte| matches!(*byte, b'"' | b'\\')) + .map(|relative| base + relative); + } + } + let tail_base = bytes.len() - chunks.remainder().len(); + chunks + .remainder() + .iter() + .position(|byte| matches!(*byte, b'"' | b'\\')) + .map(|relative| tail_base + relative) +} + fn read_next_file_bytes( reader: &mut R, file_offset: u64, @@ -2778,4 +2925,196 @@ mod lexical_page_source_tests { cursor_before, ); } + + #[test] + fn large_string_layout_scan_skips_non_structural_bytes() { + const PAYLOAD_BYTES: usize = 8 * 1024 * 1024; + let file = format!(r#"{{"payload":"{}"}}"#, "x".repeat(PAYLOAD_BYTES)); + let generation = format!(r#"{{"format_revision":6,"files":[{file}]}}"#); + let state_digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(generation.as_bytes())) + .expect("synthetic generation digest is canonical"); + let sealed = format!( + r#"{{"state_digest":"{}","generation":{generation}}}"#, + state_digest.as_str() + ) + .into_bytes(); + let first_file_offset = sealed + .windows(b"{\"payload\"".len()) + .position(|window| window == b"{\"payload\"") + .expect("synthetic file object is present"); + let files_end_offset = first_file_offset + .checked_add(file.len()) + .expect("synthetic files end fits usize"); + + let layout = scan_layout( + &mut Cursor::new(&sealed), + u64::try_from(sealed.len()).expect("synthetic seal length fits u64"), + None, + &ActiveControl, + ) + .expect("synthetic seal has a valid lexical layout"); + + assert_eq!(layout.state_digest, state_digest); + assert_eq!(layout.format_revision, 6); + assert_eq!(layout.file_count, 1); + assert_eq!( + layout.first_file_offset, + u64::try_from(first_file_offset).expect("synthetic file offset fits u64") + ); + assert_eq!( + layout.files_end_offset, + u64::try_from(files_end_offset).expect("synthetic files end fits u64") + ); + assert_eq!( + layout.maximum_file_bytes, + u64::try_from(file.len()).expect("synthetic file length fits u64") + ); + assert!( + layout.structural_byte_visits < 1024, + "an 8 MiB JSON string should require bounded structural visits, observed {}", + layout.structural_byte_visits + ); + } + + #[test] + fn layout_scan_preserves_digest_and_file_boundaries_across_escaped_syntax() { + let first_file = r#"{"payload":"escaped \\\" quote and { [ ] } syntax"}"#; + let second_file = format!(r#"{{"payload":"{}"}}"#, "y".repeat(96 * 1024)); + let generation = format!( + r#"{{"format_revision":6,"files":[{first_file},{second_file}],"tail":"done"}}"# + ); + let state_digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(generation.as_bytes())) + .expect("synthetic generation digest is canonical"); + let sealed = format!( + r#"{{"state_digest":"{}","generation":{generation}}}"#, + state_digest.as_str() + ) + .into_bytes(); + let file_digest = ManifestDigest::from_sha256_bytes(&Sha256::digest(&sealed)) + .expect("synthetic file digest is canonical"); + let first_file_offset = sealed + .windows(first_file.len()) + .position(|window| window == first_file.as_bytes()) + .expect("first synthetic file is present"); + let files_end_offset = first_file_offset + first_file.len() + 1 + second_file.len(); + + let layout = scan_layout( + &mut Cursor::new(&sealed), + u64::try_from(sealed.len()).expect("synthetic seal length fits u64"), + Some(&file_digest), + &ActiveControl, + ) + .expect("escaped syntax does not alter the authenticated layout"); + + assert_eq!(layout.state_digest, state_digest); + assert_eq!(layout.file_count, 2); + assert_eq!(layout.first_file_offset, first_file_offset as u64); + assert_eq!(layout.files_end_offset, files_end_offset as u64); + assert_eq!(layout.maximum_file_bytes, second_file.len() as u64); + } + + #[test] + fn layout_scan_rejects_cancelled_and_corrupted_sources() { + let file = format!(r#"{{"payload":"{}"}}"#, "z".repeat(512 * 1024)); + let generation = format!(r#"{{"format_revision":6,"files":[{file}]}}"#); + let state_digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(generation.as_bytes())) + .expect("synthetic generation digest is canonical"); + let sealed = format!( + r#"{{"state_digest":"{}","generation":{generation}}}"#, + state_digest.as_str() + ) + .into_bytes(); + + let cancellation = CancelDuringStaging::new(); + let cancelled = match scan_layout( + &mut Cursor::new(&sealed), + sealed.len() as u64, + None, + &cancellation, + ) { + Ok(_) => panic!("layout opening must honor bounded read checkpoints"), + Err(error) => error, + }; + assert!(matches!( + cancelled, + CodeIndexProductionErrorV1::Interrupted(CodeIndexInterruptionV1::Cancelled) + )); + + let mut corrupted = sealed; + let payload = corrupted + .windows(b"zzzz".len()) + .position(|window| window == b"zzzz") + .expect("synthetic payload is present"); + corrupted[payload] = b'x'; + let error = match scan_layout( + &mut Cursor::new(&corrupted), + corrupted.len() as u64, + None, + &ActiveControl, + ) { + Ok(_) => panic!("payload corruption must fail the exact generation digest"), + Err(error) => error, + }; + assert!(matches!(error, CodeIndexProductionErrorV1::Contract(_))); + } + + #[test] + fn layout_scanner_retains_a_constant_string_window() { + let file = format!(r#"{{"payload":"{}"}}"#, "w".repeat(8 * 1024 * 1024)); + let generation = format!(r#"{{"format_revision":6,"files":[{file}]}}"#); + let state_digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(generation.as_bytes())) + .expect("synthetic generation digest is canonical"); + let sealed = format!( + r#"{{"state_digest":"{}","generation":{generation}}}"#, + state_digest.as_str() + ) + .into_bytes(); + let mut scanner = LayoutScanner::default(); + for (chunk_ordinal, chunk) in sealed.chunks(64 * 1024).enumerate() { + scanner + .observe_slice(chunk, (chunk_ordinal * 64 * 1024) as u64) + .expect("bounded chunk scan succeeds"); + assert!(scanner.string_len <= scanner.string.len()); + assert!(std::mem::size_of::() < 1024); + } + let layout = scanner.finish().expect("bounded scanner layout verifies"); + assert_eq!(layout.file_count, 1); + assert_eq!(layout.state_digest, state_digest); + } + + #[test] + fn layout_scan_does_not_allocate_for_unrelated_short_strings() { + let values = (0..50_000) + .map(|index| format!(r#""term-{index}""#)) + .collect::>() + .join(","); + let file = format!(r#"{{"payload":[{values}]}}"#); + let generation = format!(r#"{{"format_revision":6,"files":[{file}]}}"#); + let state_digest = + ManifestDigest::from_sha256_bytes(&Sha256::digest(generation.as_bytes())) + .expect("synthetic generation digest is canonical"); + let sealed = format!( + r#"{{"state_digest":"{}","generation":{generation}}}"#, + state_digest.as_str() + ) + .into_bytes(); + + let layout = scan_layout( + &mut Cursor::new(&sealed), + sealed.len() as u64, + None, + &ActiveControl, + ) + .expect("short-string-heavy layout verifies"); + + assert!( + layout.temporary_string_allocations <= 1, + "only the authenticated state digest may require a temporary string, observed {} allocations", + layout.temporary_string_allocations + ); + } } From 3f8e9053f4b5f18530b25a8ff5a84ad805314226 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 02:38:54 +0000 Subject: [PATCH 19/69] perf(query): checkpoint setwise finalization --- .../lexical/projection/artifact/builder.rs | 139 +++++++++-- .../candidate_producers.rs | 228 +++++++++++++++++- 2 files changed, 335 insertions(+), 32 deletions(-) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index 26e4bee9bc..b379061e4d 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -1,7 +1,11 @@ use std::cmp::Reverse; use std::collections::BinaryHeap; use std::fs::File; +use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::time::Duration; use rayon::prelude::*; use rusqlite::types::ValueRef; @@ -47,6 +51,8 @@ const DOCUMENT_NGRAM_POSTINGS_QUERY: &str = "SELECT kind, ngram FROM ngram_postings WHERE document_id = ?1 ORDER BY kind, ngram"; const PROGRESS_TAIL_QUERY: &str = "SELECT page_ordinal, import_dictionary_digest, cumulative_digest, next_cursor \ FROM source_pages ORDER BY page_ordinal DESC LIMIT 1"; +const FINALIZATION_PROGRESS_INTERVAL_OPS: i32 = 4_096; +const FINALIZATION_CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(1); #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FinalizationSectionV1 { @@ -942,7 +948,10 @@ impl CodeLexicalArtifactBuilderV1 { } /// Advance durable receipt construction without rereading the sealed - /// generation. `maximum_work` bounds the number of staged rows (or empty + /// generation. Before digest verification, one wake commits exactly one + /// set-wise statistics or serving-index statement and SQLite VM progress + /// observes cancellation during that statement. During digest + /// verification, `maximum_work` bounds the number of staged rows (or empty /// section completions) this call may consume. #[hotpath::measure(label = "query.artifact.finalization.digest_verify_wake")] pub fn advance_finalization( @@ -1009,9 +1018,8 @@ impl CodeLexicalArtifactBuilderV1 { .to_owned(), )); } - let mut remaining_work = maximum_work; - advance_pre_digest_work(&transaction, &mut state, &mut remaining_work, control)?; if state.phase != PersistedFinalizationPhaseV1::Digest { + advance_pre_digest_work(&transaction, &mut state, control)?; store_finalization_state(&transaction, &state)?; checkpoint(control)?; commit_finalization_transaction(transaction, &mut transaction_metrics)?; @@ -1023,6 +1031,7 @@ impl CodeLexicalArtifactBuilderV1 { record_finalization_step(&step); return Ok(step); } + let mut remaining_work = maximum_work; let section_count = u64::try_from(SECTION_NAMES.len()).map_err(contract_number)?; while remaining_work > 0 && state.section_ordinal < section_count { checkpoint(control)?; @@ -2308,39 +2317,119 @@ fn authenticated_authority_epoch( fn advance_pre_digest_work( transaction: &Transaction<'_>, state: &mut PersistedFinalizationStateV1, - remaining_work: &mut usize, control: &dyn CodeIndexExecutionControlV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { - while *remaining_work > 0 && state.phase != PersistedFinalizationPhaseV1::Digest { - checkpoint(control)?; - match state.phase { - PersistedFinalizationPhaseV1::Statistics => { + checkpoint(control)?; + match state.phase { + PersistedFinalizationPhaseV1::Statistics => { + with_cancellable_sqlite_statement(transaction, control, || { derive_statistics_step(transaction, state.section_ordinal)?; - state.section_ordinal += 1; - if state.section_ordinal == 3 { - state.phase = PersistedFinalizationPhaseV1::Indexes; - state.section_ordinal = 0; - } + Ok(()) + })?; + state.section_ordinal = state.section_ordinal.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact statistics step overflowed".to_owned(), + ) + })?; + if state.section_ordinal == 3 { + state.phase = PersistedFinalizationPhaseV1::Indexes; + state.section_ordinal = 0; } - PersistedFinalizationPhaseV1::Indexes => { + } + PersistedFinalizationPhaseV1::Indexes => { + with_cancellable_sqlite_statement(transaction, control, || { build_serving_index_step(transaction, state.section_ordinal)?; - state.section_ordinal += 1; - if state.section_ordinal == 7 { - verify_required_artifact_indexes(transaction)?; - state.phase = PersistedFinalizationPhaseV1::Digest; - state.section_ordinal = 0; - state.section_accumulator = - initial_section_accumulator(SECTION_NAMES[0])?.to_vec(); - } + Ok(()) + })?; + state.section_ordinal = state.section_ordinal.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact serving-index step overflowed".to_owned(), + ) + })?; + if state.section_ordinal == 7 { + verify_required_artifact_indexes(transaction)?; + state.phase = PersistedFinalizationPhaseV1::Digest; + state.section_ordinal = 0; + state.section_accumulator = initial_section_accumulator(SECTION_NAMES[0])?.to_vec(); } - PersistedFinalizationPhaseV1::Digest => {} } - *remaining_work -= 1; - checkpoint(control)?; + PersistedFinalizationPhaseV1::Digest => { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact selected pre-digest work after entering digest verification" + .to_owned(), + )); + } } + checkpoint(control)?; Ok(()) } +fn with_cancellable_sqlite_statement( + transaction: &Transaction<'_>, + control: &dyn CodeIndexExecutionControlV1, + operation: impl FnOnce() -> Result, +) -> Result { + checkpoint(control)?; + let interruption = Arc::new(AtomicU8::new(0)); + let finished = Arc::new(AtomicBool::new(false)); + let monitor_ready = Arc::new(AtomicBool::new(false)); + let progress_interruption = Arc::clone(&interruption); + transaction + .progress_handler( + FINALIZATION_PROGRESS_INTERVAL_OPS, + Some(move || progress_interruption.load(Ordering::Acquire) != 0), + ) + .map_err(sqlite_error)?; + + let outcome = std::thread::scope(|scope| { + let monitor_interruption = Arc::clone(&interruption); + let monitor_finished = Arc::clone(&finished); + let monitor_ready_signal = Arc::clone(&monitor_ready); + scope.spawn(move || { + loop { + let reason = if control.is_cancelled() { + 1 + } else if control.is_deadline_exceeded() { + 2 + } else { + 0 + }; + if reason != 0 { + monitor_interruption.store(reason, Ordering::Release); + } + monitor_ready_signal.store(true, Ordering::Release); + if reason != 0 || monitor_finished.load(Ordering::Acquire) { + break; + } + std::thread::sleep(FINALIZATION_CONTROL_POLL_INTERVAL); + } + }); + while !monitor_ready.load(Ordering::Acquire) { + std::thread::yield_now(); + } + let outcome = catch_unwind(AssertUnwindSafe(operation)); + finished.store(true, Ordering::Release); + outcome + }); + transaction + .progress_handler(FINALIZATION_PROGRESS_INTERVAL_OPS, None:: bool>) + .map_err(sqlite_error)?; + + let outcome = match outcome { + Ok(outcome) => outcome, + Err(payload) => resume_unwind(payload), + }; + match interruption.load(Ordering::Acquire) { + 1 => Err(CodeLexicalArtifactErrorV1::Interrupted( + tracedecay_code_index::production::CodeIndexInterruptionV1::Cancelled, + )), + 2 => Err(CodeLexicalArtifactErrorV1::Interrupted( + tracedecay_code_index::production::CodeIndexInterruptionV1::DeadlineExceeded, + )), + _ => outcome, + } +} + fn derive_statistics_step( transaction: &Transaction<'_>, ordinal: u64, 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 7734a0aebe..dc25fdb822 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -174,6 +174,28 @@ struct CancelAtObservation { observations: AtomicUsize, } +struct CancelOnBackgroundObservation { + caller: std::thread::ThreadId, +} + +impl CancelOnBackgroundObservation { + fn new() -> Self { + Self { + caller: std::thread::current().id(), + } + } +} + +impl CodeIndexExecutionControlV1 for CancelOnBackgroundObservation { + fn is_cancelled(&self) -> bool { + std::thread::current().id() != self.caller + } + + fn is_deadline_exceeded(&self) -> bool { + false + } +} + impl CancelAtObservation { fn new(cancellation_observation: usize) -> Self { Self { @@ -1346,6 +1368,191 @@ fn disk_artifact_defers_statistics_and_serving_indexes_until_freeze() { assert_eq!(incorrect_term_stats, 0); } +#[test] +fn disk_artifact_production_wake_commits_one_restartable_setwise_step() { + let (fixture, pages, source_receipt) = real_verified_pages(); + let metadata = fixture.metadata.clone(); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("restartable-setwise-steps.sqlite"); + let control = ArtifactControl { cancelled: false }; + let mut builder = + CodeLexicalArtifactBuilderV1::create(&artifact_path, metadata.clone()).expect("create"); + for page in &pages { + builder.append_page(page, &control).expect("append page"); + } + + // Give the aggregate and index statements a production-shaped VM loop. + // These rows use an authenticated document id, so the freeze accepts the + // base relation while the later digest would still reject the deliberate + // test-only document mismatch. + let connection = rusqlite::Connection::open(&artifact_path).expect("open wide staging file"); + connection + .execute_batch( + "WITH digits(value) AS (VALUES(0),(1),(2),(3),(4),(5),(6),(7),(8),(9)), + numbers(value) AS ( + SELECT ones.value + 10 * tens.value + 100 * hundreds.value + + 1000 * thousands.value + 10000 * ten_thousands.value + FROM digits AS ones + CROSS JOIN digits AS tens + CROSS JOIN digits AS hundreds + CROSS JOIN digits AS thousands + CROSS JOIN digits AS ten_thousands + ), + authority AS ( + SELECT field, document_id FROM term_postings ORDER BY document_id LIMIT 1 + ) + INSERT INTO term_postings(field, term, document_id, frequency) + SELECT authority.field, printf('restart-wide-%05d', numbers.value), + authority.document_id, 1 + FROM authority CROSS JOIN numbers;", + ) + .expect("seed production-width posting relation"); + drop(connection); + + assert!(matches!( + builder + .advance_finalization(&source_receipt, 4_096, &control) + .expect("persist base freeze"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); + assert_eq!( + persisted_finalization_position(&artifact_path), + ("statistics".to_owned(), 0) + ); + assert!(matches!( + builder + .advance_finalization(&source_receipt, 4_096, &control) + .expect("derive only field statistics"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); + assert_eq!( + persisted_finalization_position(&artifact_path), + ("statistics".to_owned(), 1), + "a production-sized wake commits exactly one corpus-wide step" + ); + drop(builder); + + let mut resumed = CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &artifact_path, + metadata.clone(), + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("restart after committed field statistics"); + let cancellation = CancelOnBackgroundObservation::new(); + assert!(matches!( + resumed.advance_finalization(&source_receipt, 4_096, &cancellation), + Err(CodeLexicalArtifactErrorV1::Interrupted( + CodeIndexInterruptionV1::Cancelled + )) + )); + assert_eq!( + persisted_finalization_position(&artifact_path), + ("statistics".to_owned(), 1), + "cancellation inside the next SQLite statement must not advance its durable state" + ); + let connection = rusqlite::Connection::open(&artifact_path).expect("inspect cancelled step"); + let field_rows: i64 = connection + .query_row("SELECT COUNT(*) FROM field_stats", [], |row| row.get(0)) + .expect("count committed field statistics"); + let term_rows: i64 = connection + .query_row("SELECT COUNT(*) FROM term_stats", [], |row| row.get(0)) + .expect("count rolled-back term statistics"); + assert!( + field_rows > 0, + "the prior committed step survives cancellation" + ); + assert_eq!(term_rows, 0, "the interrupted step rolls back atomically"); + drop(connection); + drop(resumed); + + let mut resumed = CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &artifact_path, + metadata.clone(), + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("restart after cancelled term statistics"); + resumed + .advance_finalization(&source_receipt, 4_096, &control) + .expect("retry only term statistics"); + assert_eq!( + persisted_finalization_position(&artifact_path), + ("statistics".to_owned(), 2), + "retry resumes at the interrupted step instead of replaying the frozen prior step" + ); + drop(resumed); + + let mut expected_indexes = 0i64; + for expected_position in 0..=7u64 { + let mut resumed = + CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &artifact_path, + metadata.clone(), + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("restart between corpus-wide steps"); + resumed + .advance_finalization(&source_receipt, 4_096, &control) + .expect("advance one corpus-wide step"); + drop(resumed); + + if expected_position == 0 { + assert_eq!( + persisted_finalization_position(&artifact_path), + ("indexes".to_owned(), 0), + "the vocabulary step alone transitions to index construction" + ); + } else { + expected_indexes += 1; + let expected_state = if expected_position == 7 { + ("digest".to_owned(), 0) + } else { + ("indexes".to_owned(), expected_position) + }; + assert_eq!( + persisted_finalization_position(&artifact_path), + expected_state + ); + let connection = + rusqlite::Connection::open(&artifact_path).expect("inspect serving indexes"); + let indexes: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex_%'", + [], + |row| row.get(0), + ) + .expect("count committed serving indexes"); + assert_eq!( + indexes, expected_indexes, + "each restarted production wake commits exactly one serving index" + ); + } + } +} + +fn persisted_finalization_position(path: &Path) -> (String, u64) { + let connection = rusqlite::Connection::open(path).expect("open finalization state"); + let state: Vec = connection + .query_row( + "SELECT state FROM finalization_state WHERE singleton = 1", + [], + |row| row.get(0), + ) + .expect("read finalization state"); + let state: serde_json::Value = + serde_json::from_slice(&state).expect("decode finalization state"); + let phase = state["phase"] + .as_str() + .expect("finalization phase") + .to_owned(); + let ordinal = state["section_ordinal"] + .as_u64() + .expect("finalization section ordinal"); + (phase, ordinal) +} + #[test] fn disk_artifact_admission_selects_the_exact_largest_contiguous_prefix() { let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); @@ -1574,12 +1781,14 @@ fn disk_artifact_resume_rejects_current_revision_with_wrong_term_index_shape() { .expect("freeze current artifact"), CodeLexicalArtifactFinalizationStepV1::Pending { .. } )); - assert!(matches!( - builder - .advance_finalization(&source_receipt, 10, &control) - .expect("build serving indexes"), - CodeLexicalArtifactFinalizationStepV1::Pending { .. } - )); + for _ in 0..10 { + assert!(matches!( + builder + .advance_finalization(&source_receipt, 4_096, &control) + .expect("advance one statistics or serving-index step"), + CodeLexicalArtifactFinalizationStepV1::Pending { .. } + )); + } drop(builder); let connection = rusqlite::Connection::open(&artifact_path).expect("open index mutation"); @@ -1816,7 +2025,12 @@ fn disk_artifact_first_finalize_rejects_self_attesting_derived_mutation() { .expect("persist freeze before authenticated scan"), CodeLexicalArtifactFinalizationStepV1::Pending { .. } )); - let corrupted = builder.advance_finalization(&source_receipt, 4_096, &control); + let corrupted = loop { + match builder.advance_finalization(&source_receipt, 4_096, &control) { + Ok(CodeLexicalArtifactFinalizationStepV1::Pending { .. }) => {} + result => break result, + } + }; assert!( matches!(corrupted, Err(CodeLexicalArtifactErrorV1::Corrupt(_))), "mutated derived content must fail authenticated finalization: {corrupted:?}" From 6dfc37dfaee59cd4d22536abc9705e07966c08a6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 02:41:02 +0000 Subject: [PATCH 20/69] feat(dashboard): publish ordered catch-up progress --- .../src/code_index_freshness_api.rs | 9 +++++++-- .../codegen/schemas/dashboard-contracts.schema.json | 9 ++++++++- dashboard/src/contracts/generated.ts | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs b/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs index 73d5da8ba8..35d82f88d6 100644 --- a/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs +++ b/crates/tracedecay-dashboard-api/src/code_index_freshness_api.rs @@ -61,8 +61,12 @@ pub struct CodeIndexBuildProgressV1 { /// Durable daemon-authority epoch that produced this snapshot. /// /// This orders snapshots across daemon restarts without relying on wall - /// clock time. `progress_epoch` is comparable only within one producer - /// incarnation. + /// clock time. + pub daemon_incarnation: u64, + /// Registry-minted scheduler incarnation within one daemon. + /// + /// A worktree retirement/remount creates a new value. `progress_epoch` is + /// comparable only when both incarnation fields match. pub producer_incarnation: u64, /// Monotonic publication epoch for replacing delayed progress reads. pub progress_epoch: u64, @@ -354,6 +358,7 @@ mod tests { coverage: "complete".to_owned(), progress: Some(CodeIndexBuildProgressV1 { generation_id: "generation.catchup.01".to_owned(), + daemon_incarnation: 3, producer_incarnation: 11, progress_epoch: 7, sealed_source_digest: "sha256:sealed-source-catchup".to_owned(), diff --git a/dashboard/codegen/schemas/dashboard-contracts.schema.json b/dashboard/codegen/schemas/dashboard-contracts.schema.json index 4fc46d76ad..07f86e3add 100644 --- a/dashboard/codegen/schemas/dashboard-contracts.schema.json +++ b/dashboard/codegen/schemas/dashboard-contracts.schema.json @@ -2251,6 +2251,12 @@ "minimum": 0, "type": "integer" }, + "daemon_incarnation": { + "description": "Durable daemon-authority epoch that produced this snapshot.\n\nThis orders snapshots across daemon restarts without relying on wall\nclock time.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, "elapsed_micros": { "description": "Monotonic elapsed time for this process's active generation build.", "format": "uint64", @@ -2305,7 +2311,7 @@ "description": "Durable pipeline phase that published this snapshot." }, "producer_incarnation": { - "description": "Durable daemon-authority epoch that produced this snapshot.\n\nThis orders snapshots across daemon restarts without relying on wall\nclock time. `progress_epoch` is comparable only within one producer\nincarnation.", + "description": "Registry-minted scheduler incarnation within one daemon.\n\nA worktree retirement/remount creates a new value. `progress_epoch` is\ncomparable only when both incarnation fields match.", "format": "uint64", "minimum": 0, "type": "integer" @@ -2335,6 +2341,7 @@ }, "required": [ "generation_id", + "daemon_incarnation", "producer_incarnation", "progress_epoch", "sealed_source_digest", diff --git a/dashboard/src/contracts/generated.ts b/dashboard/src/contracts/generated.ts index 386e07a949..27e37857ca 100644 --- a/dashboard/src/contracts/generated.ts +++ b/dashboard/src/contracts/generated.ts @@ -661,6 +661,7 @@ export const CodeIndexBuildProgressV1Schema = z.object({ completed_lexical_bytes: z.number().int().safe().min(0), current_batch_pages: z.number().int().safe().min(0), current_batch_payload_bytes: z.number().int().safe().min(0), + daemon_incarnation: z.number().int().safe().min(0), elapsed_micros: z.number().int().safe().min(0), estimated_remaining_seconds: z.number().int().safe().min(0).nullable(), files_per_second: z.number().nullable(), From 022fcfad43c9adbdbaa485415f466300c2048932 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 02:51:01 +0000 Subject: [PATCH 21/69] test(dashboard): align catch-up progress fixture --- dashboard/stories/fixtures/data.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dashboard/stories/fixtures/data.ts b/dashboard/stories/fixtures/data.ts index 7d431b4911..e43d9ada78 100644 --- a/dashboard/stories/fixtures/data.ts +++ b/dashboard/stories/fixtures/data.ts @@ -3544,6 +3544,8 @@ function codeIndexBuildProgressFixture( ): Record { return { generation_id: 'generation.catchup.01', + daemon_incarnation: 1, + producer_incarnation: 1, progress_epoch: 1, sealed_source_digest: 'sha256:sealed-source-catchup', phase: 'bulk_commit', From 5d142e1054b4a071b7c77c2665357e0fcf1c9afc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 03:07:27 +0000 Subject: [PATCH 22/69] fix(query): bound catch-up batch admission --- .../lexical/projection/artifact/builder.rs | 297 +++++++++++++++--- .../candidate_producers.rs | 49 +++ 2 files changed, 311 insertions(+), 35 deletions(-) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index b379061e4d..cf46b0de0a 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -54,6 +54,16 @@ const PROGRESS_TAIL_QUERY: &str = "SELECT page_ordinal, import_dictionary_digest const FINALIZATION_PROGRESS_INTERVAL_OPS: i32 = 4_096; const FINALIZATION_CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(1); +#[cfg(test)] +std::thread_local! { + static FAIL_NEXT_FINALIZATION_MONITOR_SPAWN: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +fn fail_next_finalization_monitor_spawn() { + FAIL_NEXT_FINALIZATION_MONITOR_SPAWN.with(|failure| failure.set(true)); +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FinalizationSectionV1 { SourcePages, @@ -651,9 +661,10 @@ impl CodeLexicalArtifactBuilderV1 { } /// Return the largest contiguous input prefix whose complete retained, - /// prepared-output, and active-worker scratch claim fits this builder. - /// Zero is truthful when even the first page cannot be admitted; callers - /// retain the source cursor and can surface the existing typed bound. + /// prepared-output, active-worker scratch, prepared-row, and estimated + /// SQLite-write claims fit every canonical batch limit. Zero is truthful + /// when even the first page cannot be admitted; callers retain the source + /// cursor and can surface the existing typed bound. pub fn largest_admissible_page_prefix( &self, pages: &[VerifiedSealedLexicalPageV1], @@ -664,6 +675,7 @@ impl CodeLexicalArtifactBuilderV1 { let mut prepared = 0usize; let mut active_scratch = 0usize; let mut largest_scratch = BinaryHeap::>::new(); + let mut canonical_limits = CanonicalBatchLimitLedgerV1::default(); for (index, page) in pages.iter().enumerate() { if page.retained_owned_bytes() > CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 { return Err(CodeLexicalArtifactErrorV1::Contract(format!( @@ -705,6 +717,11 @@ impl CodeLexicalArtifactBuilderV1 { if required > self.memory_budget_bytes { return Ok(index); } + let (estimated_rows, estimated_write_bytes) = + page_prepared_write_upper_bounds(&self.metadata, page)?; + if !canonical_limits.try_admit(estimated_rows, estimated_write_bytes)? { + return Ok(index); + } } Ok(pages.len()) } @@ -953,7 +970,7 @@ impl CodeLexicalArtifactBuilderV1 { /// observes cancellation during that statement. During digest /// verification, `maximum_work` bounds the number of staged rows (or empty /// section completions) this call may consume. - #[hotpath::measure(label = "query.artifact.finalization.digest_verify_wake")] + #[hotpath::measure(label = "query.artifact.finalization.advance_wake")] pub fn advance_finalization( &mut self, source: &VerifiedSealedLexicalSourceReceiptV1, @@ -1397,6 +1414,96 @@ fn page_batch_ledger_charge_bytes( }) } +#[derive(Default)] +struct CanonicalBatchLimitLedgerV1 { + estimated_rows: usize, + estimated_write_bytes: usize, +} + +impl CanonicalBatchLimitLedgerV1 { + fn try_admit( + &mut self, + page_rows: usize, + page_write_bytes: usize, + ) -> Result { + let estimated_rows = self.estimated_rows.checked_add(page_rows).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch row preflight overflowed".to_owned(), + ) + })?; + let estimated_write_bytes = self + .estimated_write_bytes + .checked_add(page_write_bytes) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact batch write preflight overflowed".to_owned(), + ) + })?; + if estimated_rows > CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1 + || estimated_write_bytes > CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1 + { + return Ok(false); + } + self.estimated_rows = estimated_rows; + self.estimated_write_bytes = estimated_write_bytes; + Ok(true) + } +} + +/// Allocation-free upper bounds for the two canonical post-preparation +/// admission metrics. The retained-output authority already covers every +/// serialized row, posting key, and n-gram tuple; import evidence needs one +/// additional canonical copy because SQLite writes it to two authority +/// tables. +fn page_prepared_write_upper_bounds( + metadata: &CodeLexicalProjectionMetadataV1, + page: &VerifiedSealedLexicalPageV1, +) -> Result<(usize, usize), CodeLexicalArtifactErrorV1> { + let mut rows = 1usize; + let mut write_bytes = page_prepared_retained_upper_bound_bytes(metadata, page)?; + for evidence in page.imports() { + rows = rows.checked_add(2).ok_or_else(batch_ledger_overflow)?; + write_bytes = write_bytes + .checked_add(import_transient_bytes(evidence)?) + .and_then(|bytes| bytes.checked_add(page.page_digest().as_str().len())) + .ok_or_else(batch_ledger_overflow)?; + } + for admitted in page.chunks() { + let chunk = admitted.chunk(); + let text_bytes = chunk.sanitized_text.as_str().len(); + let (normalized_ngrams, _) = document_ngram_scratch(text_bytes)?; + let raw_ngrams = if chunk + .sanitized_text + .as_str() + .as_bytes() + .iter() + .any(u8::is_ascii_uppercase) + { + document_ngram_scratch(text_bytes)?.0 + } else { + 0 + }; + let term_postings = text_bytes + .checked_add(1) + .and_then(|count| count.checked_add(chunk.subtokens.len())) + .and_then(|count| count.checked_add(chunk.exact_terms.len().checked_mul(2)?)) + .ok_or_else(batch_ledger_overflow)?; + let exact_postings = chunk + .exact_terms + .len() + .checked_add(1) + .ok_or_else(batch_ledger_overflow)?; + rows = rows + .checked_add(2) + .and_then(|count| count.checked_add(term_postings)) + .and_then(|count| count.checked_add(exact_postings)) + .and_then(|count| count.checked_add(normalized_ngrams)) + .and_then(|count| count.checked_add(raw_ngrams)) + .ok_or_else(batch_ledger_overflow)?; + } + Ok((rows, write_bytes)) +} + /// Refuse a batch unless its retained source pages, all prepared outputs, and /// one scratch peak per active worker fit together. Admission runs before the /// staging transaction, so refusal leaves builder and source progress intact. @@ -2372,7 +2479,6 @@ fn with_cancellable_sqlite_statement( checkpoint(control)?; let interruption = Arc::new(AtomicU8::new(0)); let finished = Arc::new(AtomicBool::new(false)); - let monitor_ready = Arc::new(AtomicBool::new(false)); let progress_interruption = Arc::clone(&interruption); transaction .progress_handler( @@ -2381,11 +2487,12 @@ fn with_cancellable_sqlite_statement( ) .map_err(sqlite_error)?; - let outcome = std::thread::scope(|scope| { + let monitored = std::thread::scope(|scope| { let monitor_interruption = Arc::clone(&interruption); let monitor_finished = Arc::clone(&finished); - let monitor_ready_signal = Arc::clone(&monitor_ready); - scope.spawn(move || { + let (ready_sender, ready_receiver) = std::sync::mpsc::sync_channel(0); + let monitor = spawn_finalization_control_monitor(scope, move || { + let mut ready = false; loop { let reason = if control.is_cancelled() { 1 @@ -2397,25 +2504,54 @@ fn with_cancellable_sqlite_statement( if reason != 0 { monitor_interruption.store(reason, Ordering::Release); } - monitor_ready_signal.store(true, Ordering::Release); + if !ready { + let _ = ready_sender.send(()); + ready = true; + } if reason != 0 || monitor_finished.load(Ordering::Acquire) { break; } std::thread::sleep(FINALIZATION_CONTROL_POLL_INTERVAL); } - }); - while !monitor_ready.load(Ordering::Acquire) { - std::thread::yield_now(); - } - let outcome = catch_unwind(AssertUnwindSafe(operation)); + })?; + let readiness = ready_receiver.recv(); + let outcome = readiness + .as_ref() + .ok() + .map(|_| catch_unwind(AssertUnwindSafe(operation))); finished.store(true, Ordering::Release); - outcome + Ok::<_, std::io::Error>((readiness, outcome, monitor.join())) }); - transaction + let clear = transaction .progress_handler(FINALIZATION_PROGRESS_INTERVAL_OPS, None:: bool>) - .map_err(sqlite_error)?; + .map_err(sqlite_error); + clear?; - let outcome = match outcome { + let (readiness, outcome, monitor) = monitored.map_err(|error| { + CodeLexicalArtifactErrorV1::Io(format!( + "lexical artifact finalization cancellation monitor could not start: {error}" + )) + })?; + if let Err(payload) = monitor { + return Err(CodeLexicalArtifactErrorV1::Io( + tracedecay_code_index::parallelism::CodeIndexParallelismErrorV1::from_panic_payload( + 0, &*payload, + ) + .to_string(), + )); + } + readiness.map_err(|error| { + CodeLexicalArtifactErrorV1::Io(format!( + "lexical artifact finalization cancellation monitor stopped before readiness: {error}" + )) + })?; + + let outcome = match outcome.ok_or_else(|| { + CodeLexicalArtifactErrorV1::Io( + "lexical artifact finalization cancellation monitor produced no operation outcome" + .to_owned(), + ) + })? { Ok(outcome) => outcome, Err(payload) => resume_unwind(payload), }; @@ -2430,6 +2566,24 @@ fn with_cancellable_sqlite_statement( } } +fn spawn_finalization_control_monitor<'scope, 'environment>( + scope: &'scope std::thread::Scope<'scope, 'environment>, + monitor: impl FnOnce() + Send + 'scope, +) -> std::io::Result> +where + 'environment: 'scope, +{ + #[cfg(test)] + if FAIL_NEXT_FINALIZATION_MONITOR_SPAWN.with(std::cell::Cell::take) { + return Err(std::io::Error::other( + "injected finalization monitor spawn failure", + )); + } + std::thread::Builder::new() + .name("tracedecay-lexical-finalization-control".to_owned()) + .spawn_scoped(scope, monitor) +} + fn derive_statistics_step( transaction: &Transaction<'_>, ordinal: u64, @@ -2480,28 +2634,54 @@ fn build_serving_index_step( transaction: &Transaction<'_>, ordinal: u64, ) -> Result<(), CodeLexicalArtifactErrorV1> { - let sql = match ordinal { - 0 => "CREATE UNIQUE INDEX rows_by_chunk ON rows(chunk_id)", - 1 => "CREATE INDEX term_postings_by_term ON term_postings(term, field, document_id)", - 2 => { - "CREATE INDEX term_postings_by_document ON term_postings(document_id, field, term, frequency)" - } - 3 => { - "CREATE INDEX term_postings_by_document_term ON term_postings(document_id, term, field, frequency)" - } - 4 => "CREATE INDEX term_stats_by_term ON term_stats(term, field)", - 5 => "CREATE INDEX exact_postings_by_document ON exact_postings(document_id, field, term)", - 6 => "CREATE INDEX ngram_postings_by_document ON ngram_postings(document_id, kind, ngram)", + match ordinal { + 0 => hotpath::measure_block!( + "query.artifact.finalization.index.rows_by_chunk", + transaction.execute_batch("CREATE UNIQUE INDEX rows_by_chunk ON rows(chunk_id)") + ), + 1 => hotpath::measure_block!( + "query.artifact.finalization.index.term_postings_by_term", + transaction.execute_batch( + "CREATE INDEX term_postings_by_term ON term_postings(term, field, document_id)", + ) + ), + 2 => hotpath::measure_block!( + "query.artifact.finalization.index.term_postings_by_document", + transaction.execute_batch( + "CREATE INDEX term_postings_by_document ON term_postings(document_id, field, term, frequency)", + ) + ), + 3 => hotpath::measure_block!( + "query.artifact.finalization.index.term_postings_by_document_term", + transaction.execute_batch( + "CREATE INDEX term_postings_by_document_term ON term_postings(document_id, term, field, frequency)", + ) + ), + 4 => hotpath::measure_block!( + "query.artifact.finalization.index.term_stats_by_term", + transaction.execute_batch( + "CREATE INDEX term_stats_by_term ON term_stats(term, field)", + ) + ), + 5 => hotpath::measure_block!( + "query.artifact.finalization.index.exact_postings_by_document", + transaction.execute_batch( + "CREATE INDEX exact_postings_by_document ON exact_postings(document_id, field, term)", + ) + ), + 6 => hotpath::measure_block!( + "query.artifact.finalization.index.ngram_postings_by_document", + transaction.execute_batch( + "CREATE INDEX ngram_postings_by_document ON ngram_postings(document_id, kind, ngram)", + ) + ), _ => { return Err(CodeLexicalArtifactErrorV1::Corrupt( "lexical artifact selected an unknown serving-index step".to_owned(), )); } - }; - hotpath::measure_block!( - "query.artifact.finalization.build_serving_index", - transaction.execute_batch(sql).map_err(sqlite_error) - ) + } + .map_err(sqlite_error) } impl PersistedFinalizationStateV1 { @@ -3840,6 +4020,53 @@ mod tests { } } + #[test] + fn canonical_batch_limits_select_a_multi_page_prefix_without_stalling() { + let page_bounds = [ + (700_000usize, 80 * 1024 * 1024usize), + (700_000, 80 * 1024 * 1024), + (700_000, 80 * 1024 * 1024), + ]; + let mut ledger = CanonicalBatchLimitLedgerV1::default(); + let selected = page_bounds + .into_iter() + .take_while(|(rows, bytes)| { + ledger + .try_admit(*rows, *bytes) + .expect("extend canonical limit ledger") + }) + .count(); + assert_eq!( + selected, 2, + "two pages fit both canonical caps and the third must remain for the next wake" + ); + } + + #[test] + fn finalization_monitor_spawn_failure_is_typed_and_leaves_sqlite_reusable() { + let mut connection = Connection::open_in_memory().expect("open artifact database"); + create_schema(&connection).expect("create artifact schema"); + let transaction = connection.transaction().expect("start transaction"); + fail_next_finalization_monitor_spawn(); + let operation_ran = AtomicBool::new(false); + let error = with_cancellable_sqlite_statement(&transaction, &ActiveControl, || { + operation_ran.store(true, Ordering::SeqCst); + Ok(()) + }) + .expect_err("injected monitor spawn failure must be typed"); + assert!(matches!(error, CodeLexicalArtifactErrorV1::Io(_))); + assert!( + !operation_ran.load(Ordering::SeqCst), + "SQLite work must not start without its cancellation monitor" + ); + with_cancellable_sqlite_statement(&transaction, &ActiveControl, || { + transaction + .query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) + .map_err(sqlite_error) + }) + .expect("progress handler is cleared after spawn failure"); + } + #[test] fn persisted_progress_tail_seeks_latest_maintained_cursor_without_full_scan() { let connection = Connection::open_in_memory().expect("open progress database"); 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 dc25fdb822..8d3561e191 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -1589,6 +1589,55 @@ fn disk_artifact_admission_selects_the_exact_largest_contiguous_prefix() { ); } +#[test] +fn disk_artifact_admission_keeps_real_pages_wide_until_the_actual_limit() { + let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); + assert!( + pages.len() >= 3, + "parser-backed fixture must expose a three-page boundary" + ); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let probe = CodeLexicalArtifactBuilderV1::create( + directory.path().join("wide-prefix-probe.sqlite"), + fixture.metadata.clone(), + ) + .expect("create admission probe"); + let two_page_charge = probe + .page_batch_ledger_charge_bytes(&pages[..2]) + .expect("measure two-page charge"); + let three_page_charge = probe + .page_batch_ledger_charge_bytes(&pages[..3]) + .expect("measure three-page charge"); + let exact_budget = probe + .fixed_ledger_charge_bytes() + .checked_add(two_page_charge) + .expect("exact two-page budget"); + assert!( + probe.fixed_ledger_charge_bytes() + three_page_charge > exact_budget, + "the third real page must be the actual memory authority boundary" + ); + drop(probe); + + let artifact_path = directory.path().join("wide-prefix.sqlite"); + let mut builder = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &artifact_path, + fixture.metadata, + exact_budget, + ) + .expect("create exactly bounded builder"); + let selected = builder + .largest_admissible_page_prefix(&pages) + .expect("select real parser-backed prefix"); + assert_eq!( + selected, 2, + "all-limit preflight must preserve a two-page batch and stop at its real third-page bound" + ); + let progress = builder + .append_pages(&pages[..selected], &ArtifactControl { cancelled: false }) + .expect("the selected multi-page prefix must pass exact post-preparation admission"); + assert_eq!(progress.next_page_ordinal, 2); +} + #[test] fn disk_artifact_finalization_resumes_after_restart_without_source_replay() { let (mut fixture, pages, source_receipt) = real_verified_pages_with_maximum_page_chunks(1); From 1af4fd6f0afe5074fc6fd04bc0b8650025ab3216 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 03:14:34 +0000 Subject: [PATCH 23/69] perf(hotpath): instrument sealed decode and artifact publish --- .../src/production/sealed_codec.rs | 10 +- src/daemon/code_index_scheduler.rs | 125 +++++++++--------- 2 files changed, 73 insertions(+), 62 deletions(-) diff --git a/crates/tracedecay-code-index/src/production/sealed_codec.rs b/crates/tracedecay-code-index/src/production/sealed_codec.rs index 05f9cfa7af..df99e71104 100644 --- a/crates/tracedecay-code-index/src/production/sealed_codec.rs +++ b/crates/tracedecay-code-index/src/production/sealed_codec.rs @@ -543,7 +543,10 @@ impl CodeIndexPublishedGenerationV1 { )) }) )?; - let expected_digest = json_generation_digest(raw.generation.get().as_bytes())?; + let expected_digest = hotpath::measure_block!( + "code_index.sealed_decode.v6_payload_digest", + json_generation_digest(raw.generation.get().as_bytes()) + )?; if expected_digest != raw.state_digest { return Err(CodeIndexProductionErrorV1::Contract( "sealed generation state digest does not match its payload".to_owned(), @@ -685,7 +688,10 @@ impl CodeIndexPublishedGenerationV1 { chunk_policy: OnceLock::new(), graph_manifest: OnceLock::new(), }; - generation.validate_fresh()?; + hotpath::measure_block!( + "code_index.sealed_decode.corpus_validation", + generation.validate_fresh() + )?; Ok(generation) } diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index fc6981524c..5b94c6c1e3 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -2164,70 +2164,75 @@ impl DaemonCodeTextArtifactStoreV1 { sealed_identity: &DurableSealedCodeGenerationIdentityV1, control: &dyn CodeIndexExecutionControlV1, ) -> Result { - let artifacts_root = code_text_artifacts_root(&self.store_root); - ensure_private_text_artifacts_root(&artifacts_root)?; - // Publication and artifact retention share this canonical store lock. - // Hold it from the first staging observation until pointer attachment - // is durable so retention cannot unlink a newly visible artifact from - // a plan made before the descriptor was attached. - let lock = acquire_code_generation_store_lock(&self.store_root) - .map_err(text_artifact_unavailable)?; - let (artifact_hex, artifact_size_bytes) = - sha256_private_file_hex_and_size(staging_path, control)?; - let descriptor = DurableCodeTextArtifactDescriptorV1 { - generation_id: generation.manifest().generation_id.clone(), - artifact_file: format!("text-artifact-{artifact_hex}.bin"), - artifact_digest: ManifestDigest::from_sha256_bytes( - &hex::decode(&artifact_hex).map_err(text_artifact_unavailable)?, - ) - .map_err(text_artifact_unavailable)?, - artifact_size_bytes, - }; - let final_path = artifacts_root.join(&descriptor.artifact_file); - match final_path.symlink_metadata() { - Ok(_) => { - // A digest-derived name is not proof that an existing filesystem - // object contains the named bytes. Verify the stable destination - // before withdrawing staging evidence; a symlink, non-regular - // object, truncated file, or same-name collision fails closed. - let (existing_hex, existing_size_bytes) = - sha256_private_file_hex_and_size(&final_path, control)?; - if existing_size_bytes != artifact_size_bytes { - return Err(RetrievalPortError::Contract( - "existing code text artifact does not match its content address".to_owned(), - )); + hotpath::measure_block!("query.artifact.store.publish", { + let artifacts_root = code_text_artifacts_root(&self.store_root); + ensure_private_text_artifacts_root(&artifacts_root)?; + // Publication and artifact retention share this canonical store lock. + // Hold it from the first staging observation until pointer attachment + // is durable so retention cannot unlink a newly visible artifact from + // a plan made before the descriptor was attached. + let lock = acquire_code_generation_store_lock(&self.store_root) + .map_err(text_artifact_unavailable)?; + let (artifact_hex, artifact_size_bytes) = + sha256_private_file_hex_and_size(staging_path, control)?; + let descriptor = DurableCodeTextArtifactDescriptorV1 { + generation_id: generation.manifest().generation_id.clone(), + artifact_file: format!("text-artifact-{artifact_hex}.bin"), + artifact_digest: ManifestDigest::from_sha256_bytes( + &hex::decode(&artifact_hex).map_err(text_artifact_unavailable)?, + ) + .map_err(text_artifact_unavailable)?, + artifact_size_bytes, + }; + let final_path = artifacts_root.join(&descriptor.artifact_file); + match final_path.symlink_metadata() { + Ok(_) => { + // A digest-derived name is not proof that an existing filesystem + // object contains the named bytes. Verify the stable destination + // before withdrawing staging evidence; a symlink, non-regular + // object, truncated file, or same-name collision fails closed. + let (existing_hex, existing_size_bytes) = + sha256_private_file_hex_and_size(&final_path, control)?; + if existing_size_bytes != artifact_size_bytes { + return Err(RetrievalPortError::Contract( + "existing code text artifact does not match its content address" + .to_owned(), + )); + } + if existing_hex != artifact_hex { + return Err(RetrievalPortError::Contract( + "existing code text artifact contains different bytes".to_owned(), + )); + } + std::fs::remove_file(staging_path).map_err(text_artifact_unavailable)?; } - if existing_hex != artifact_hex { - return Err(RetrievalPortError::Contract( - "existing code text artifact contains different bytes".to_owned(), - )); + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::rename(staging_path, &final_path) + .map_err(text_artifact_unavailable)?; } - std::fs::remove_file(staging_path).map_err(text_artifact_unavailable)?; + Err(error) => return Err(text_artifact_unavailable(error)), } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::rename(staging_path, &final_path).map_err(text_artifact_unavailable)?; - } - Err(error) => return Err(text_artifact_unavailable(error)), - } - DaemonCodeIndexPublicationStoreV1::sync_directory(&artifacts_root) + DaemonCodeIndexPublicationStoreV1::sync_directory(&artifacts_root) + .map_err(text_artifact_unavailable)?; + let pointer = self + .publication + .read_publication_pointer() + .map_err(text_artifact_unavailable)? + .ok_or_else(|| { + RetrievalPortError::AuthorityUnavailable( + "no durable publication pointer exists for text-artifact attachment" + .to_owned(), + ) + })?; + attach_verified_text_artifact_under_lock( + &lock, + &pointer, + sealed_identity, + descriptor.clone(), + ) .map_err(text_artifact_unavailable)?; - let pointer = self - .publication - .read_publication_pointer() - .map_err(text_artifact_unavailable)? - .ok_or_else(|| { - RetrievalPortError::AuthorityUnavailable( - "no durable publication pointer exists for text-artifact attachment".to_owned(), - ) - })?; - attach_verified_text_artifact_under_lock( - &lock, - &pointer, - sealed_identity, - descriptor.clone(), - ) - .map_err(text_artifact_unavailable)?; - Ok(descriptor) + Ok(descriptor) + }) } } From b76498527576ca893a2b1a45f444e69d55cd6dc5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 03:32:59 +0000 Subject: [PATCH 24/69] fix(query): select exact prepared page prefix --- .../tracedecay-query/src/retrieval/lexical.rs | 4 +- .../src/retrieval/lexical/projection.rs | 3 +- .../retrieval/lexical/projection/artifact.rs | 1 + .../lexical/projection/artifact/builder.rs | 267 ++++++++++++------ .../candidate_producers.rs | 93 ++++-- src/daemon/code_index_scheduler.rs | 74 +++-- 6 files changed, 301 insertions(+), 141 deletions(-) diff --git a/crates/tracedecay-query/src/retrieval/lexical.rs b/crates/tracedecay-query/src/retrieval/lexical.rs index df7874f21b..6dea5b362e 100644 --- a/crates/tracedecay-query/src/retrieval/lexical.rs +++ b/crates/tracedecay-query/src/retrieval/lexical.rs @@ -36,8 +36,8 @@ pub use self::projection::{ CodeLexicalArtifactSectionDigestV1, CodeLexicalImportMembershipWitnessV1, CodeLexicalProjectionAdapterV1, CodeLexicalProjectionBuildStepV1, CodeLexicalProjectionBuildV1, CodeLexicalProjectionMetadataV1, LEXICAL_PROJECTION_BUILD_DEADLINE_MICROS_V1, - PreparedCodeLexicalArtifactPageV1, VerifiedCodeLexicalArtifactV1, - lexical_projection_build_deadline_micros, + PreparedCodeLexicalArtifactBatchV1, PreparedCodeLexicalArtifactPageV1, + VerifiedCodeLexicalArtifactV1, lexical_projection_build_deadline_micros, }; /// Wording the lexical lane uses when a port-emitted batch fails the shared diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection.rs b/crates/tracedecay-query/src/retrieval/lexical/projection.rs index 8f9870ffe0..974bb80703 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection.rs @@ -36,7 +36,8 @@ pub use artifact::{ CodeLexicalArtifactFinalizationPhaseV1, CodeLexicalArtifactFinalizationStepV1, CodeLexicalArtifactOccurrenceV1, CodeLexicalArtifactReaderV1, CodeLexicalArtifactSectionDigestV1, CodeLexicalImportMembershipWitnessV1, - PreparedCodeLexicalArtifactPageV1, VerifiedCodeLexicalArtifactV1, + PreparedCodeLexicalArtifactBatchV1, PreparedCodeLexicalArtifactPageV1, + VerifiedCodeLexicalArtifactV1, }; use postings::{ByteNgramBudget, ByteNgramPostings, FuzzyTermIndex}; diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs index 507cef2736..7a3f9198ae 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs @@ -18,6 +18,7 @@ mod reader; pub use builder::{ CodeLexicalArtifactBuildProgressV1, CodeLexicalArtifactBuilderV1, CodeLexicalArtifactFinalizationPhaseV1, CodeLexicalArtifactFinalizationStepV1, + PreparedCodeLexicalArtifactBatchV1, }; pub use format::{ CodeLexicalArtifactOccurrenceV1, CodeLexicalArtifactSectionDigestV1, diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index cf46b0de0a..babef9cb06 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -1,6 +1,7 @@ use std::cmp::Reverse; use std::collections::BinaryHeap; use std::fs::File; +use std::num::NonZeroUsize; use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -515,6 +516,24 @@ pub struct CodeLexicalArtifactBuilderV1 { fixed_ledger_charge_bytes: usize, } +/// One source-prefix decision whose fresh relational values were prepared +/// exactly once. Replayed pages contribute to `accepted_prefix` but do not +/// appear in `prepared_pages` because they require no SQLite mutation. +pub struct PreparedCodeLexicalArtifactBatchV1 { + accepted_prefix: NonZeroUsize, + prepared_pages: Vec, +} + +impl PreparedCodeLexicalArtifactBatchV1 { + pub fn accepted_prefix(&self) -> NonZeroUsize { + self.accepted_prefix + } + + pub fn prepared_pages(&self) -> &[PreparedCodeLexicalArtifactPageV1] { + &self.prepared_pages + } +} + impl CodeLexicalArtifactBuilderV1 { pub fn create( path: impl AsRef, @@ -661,10 +680,10 @@ impl CodeLexicalArtifactBuilderV1 { } /// Return the largest contiguous input prefix whose complete retained, - /// prepared-output, active-worker scratch, prepared-row, and estimated - /// SQLite-write claims fit every canonical batch limit. Zero is truthful - /// when even the first page cannot be admitted; callers retain the source - /// cursor and can surface the existing typed bound. + /// prepared-output, and active-worker scratch claims fit the memory + /// authority. Exact prepared-row and SQLite-write prefix selection occurs + /// after this bound in [`Self::prepare_admissible_page_prefix`]. Zero is + /// truthful when even the first page cannot be prepared within memory. pub fn largest_admissible_page_prefix( &self, pages: &[VerifiedSealedLexicalPageV1], @@ -675,7 +694,6 @@ impl CodeLexicalArtifactBuilderV1 { let mut prepared = 0usize; let mut active_scratch = 0usize; let mut largest_scratch = BinaryHeap::>::new(); - let mut canonical_limits = CanonicalBatchLimitLedgerV1::default(); for (index, page) in pages.iter().enumerate() { if page.retained_owned_bytes() > CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 { return Err(CodeLexicalArtifactErrorV1::Contract(format!( @@ -717,11 +735,6 @@ impl CodeLexicalArtifactBuilderV1 { if required > self.memory_budget_bytes { return Ok(index); } - let (estimated_rows, estimated_write_bytes) = - page_prepared_write_upper_bounds(&self.metadata, page)?; - if !canonical_limits.try_admit(estimated_rows, estimated_write_bytes)? { - return Ok(index); - } } Ok(pages.len()) } @@ -762,6 +775,87 @@ impl CodeLexicalArtifactBuilderV1 { pages: &[VerifiedSealedLexicalPageV1], control: &dyn CodeIndexExecutionControlV1, ) -> Result, CodeLexicalArtifactErrorV1> { + let (_, prepared) = self.prepare_pages_inner(pages, control)?; + admit_prepared_page_batch( + self.fixed_ledger_charge_bytes, + self.memory_budget_bytes, + &prepared, + )?; + record_prepared_batch_metrics(&prepared); + Ok(prepared) + } + + /// Memory-bound an offered source batch, prepare that prefix once, then + /// select the largest exact prepared prefix admitted by the row and + /// estimated-write authorities. This avoids conservative pre-dedup row + /// estimates while preserving every exact post-preparation cap. + pub fn prepare_admissible_page_prefix( + &self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result { + if pages.is_empty() { + return Err(CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page batches must be non-empty".to_owned(), + )); + } + let memory_prefix = self.largest_admissible_page_prefix(pages)?; + if memory_prefix == 0 { + record_batch_prefix_limit(CodeLexicalArtifactBatchLimitV1::Memory); + admit_page_batch_within_memory_budget( + &self.metadata, + self.fixed_ledger_charge_bytes, + self.memory_budget_bytes, + &pages[..1], + )?; + return Err(CodeLexicalArtifactErrorV1::Contract( + "lexical artifact memory prefix rejected a separately admissible first page" + .to_owned(), + )); + } + let (replayed_prefix, mut prepared) = + self.prepare_pages_inner(&pages[..memory_prefix], control)?; + let (fresh_prefix, exact_limit) = largest_exact_prepared_prefix(&prepared)?; + if fresh_prefix == 0 && !prepared.is_empty() { + let exceeded = exact_limit.ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact exact prefix rejected a page without a limiting authority" + .to_owned(), + ) + })?; + record_batch_prefix_limit(exceeded.limit); + return Err(batch_limit( + exceeded.limit, + exceeded.required, + exceeded.maximum, + )); + } + prepared.truncate(fresh_prefix); + let accepted = replayed_prefix + .checked_add(fresh_prefix) + .ok_or_else(batch_ledger_overflow)?; + let accepted_prefix = NonZeroUsize::new(accepted).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact admissible source prefix was empty".to_owned(), + ) + })?; + if let Some(exceeded) = exact_limit { + record_batch_prefix_limit(exceeded.limit); + } else if memory_prefix < pages.len() { + record_batch_prefix_limit(CodeLexicalArtifactBatchLimitV1::Memory); + } + record_prepared_batch_metrics(&prepared); + Ok(PreparedCodeLexicalArtifactBatchV1 { + accepted_prefix, + prepared_pages: prepared, + }) + } + + fn prepare_pages_inner( + &self, + pages: &[VerifiedSealedLexicalPageV1], + control: &dyn CodeIndexExecutionControlV1, + ) -> Result<(usize, Vec), CodeLexicalArtifactErrorV1> { checkpoint(control)?; self.verify_path_binding()?; if pages.is_empty() { @@ -792,7 +886,7 @@ impl CodeLexicalArtifactBuilderV1 { })?; let fresh_pages = &pages[fresh_start..]; if fresh_pages.is_empty() { - return Ok(Vec::new()); + return Ok((fresh_start, Vec::new())); } let previous_cursors = fresh_pages .iter() @@ -845,13 +939,7 @@ impl CodeLexicalArtifactBuilderV1 { .map_err(|error| CodeLexicalArtifactErrorV1::Io(error.to_string()))? .into_iter() .collect::, _>>()?; - admit_prepared_page_batch( - self.fixed_ledger_charge_bytes, - self.memory_budget_bytes, - &prepared, - )?; - record_prepared_batch_metrics(&prepared); - Ok(prepared) + Ok((fresh_start, prepared)) } /// Atomically admit an ordered prepared batch. The values carry no @@ -1420,12 +1508,19 @@ struct CanonicalBatchLimitLedgerV1 { estimated_write_bytes: usize, } +#[derive(Clone, Copy)] +struct BatchLimitExceededV1 { + limit: CodeLexicalArtifactBatchLimitV1, + required: usize, + maximum: usize, +} + impl CanonicalBatchLimitLedgerV1 { fn try_admit( &mut self, page_rows: usize, page_write_bytes: usize, - ) -> Result { + ) -> Result, CodeLexicalArtifactErrorV1> { let estimated_rows = self.estimated_rows.checked_add(page_rows).ok_or_else(|| { CodeLexicalArtifactErrorV1::Contract( "lexical artifact batch row preflight overflowed".to_owned(), @@ -1439,69 +1534,38 @@ impl CanonicalBatchLimitLedgerV1 { "lexical artifact batch write preflight overflowed".to_owned(), ) })?; - if estimated_rows > CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1 - || estimated_write_bytes > CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1 - { - return Ok(false); + if estimated_rows > CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1 { + return Ok(Some(BatchLimitExceededV1 { + limit: CodeLexicalArtifactBatchLimitV1::PreparedRows, + required: estimated_rows, + maximum: CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1, + })); + } + if estimated_write_bytes > CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1 { + return Ok(Some(BatchLimitExceededV1 { + limit: CodeLexicalArtifactBatchLimitV1::EstimatedWriteBytes, + required: estimated_write_bytes, + maximum: CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1, + })); } self.estimated_rows = estimated_rows; self.estimated_write_bytes = estimated_write_bytes; - Ok(true) + Ok(None) } } -/// Allocation-free upper bounds for the two canonical post-preparation -/// admission metrics. The retained-output authority already covers every -/// serialized row, posting key, and n-gram tuple; import evidence needs one -/// additional canonical copy because SQLite writes it to two authority -/// tables. -fn page_prepared_write_upper_bounds( - metadata: &CodeLexicalProjectionMetadataV1, - page: &VerifiedSealedLexicalPageV1, -) -> Result<(usize, usize), CodeLexicalArtifactErrorV1> { - let mut rows = 1usize; - let mut write_bytes = page_prepared_retained_upper_bound_bytes(metadata, page)?; - for evidence in page.imports() { - rows = rows.checked_add(2).ok_or_else(batch_ledger_overflow)?; - write_bytes = write_bytes - .checked_add(import_transient_bytes(evidence)?) - .and_then(|bytes| bytes.checked_add(page.page_digest().as_str().len())) - .ok_or_else(batch_ledger_overflow)?; - } - for admitted in page.chunks() { - let chunk = admitted.chunk(); - let text_bytes = chunk.sanitized_text.as_str().len(); - let (normalized_ngrams, _) = document_ngram_scratch(text_bytes)?; - let raw_ngrams = if chunk - .sanitized_text - .as_str() - .as_bytes() - .iter() - .any(u8::is_ascii_uppercase) +fn largest_exact_prepared_prefix( + pages: &[PreparedCodeLexicalArtifactPageV1], +) -> Result<(usize, Option), CodeLexicalArtifactErrorV1> { + let mut ledger = CanonicalBatchLimitLedgerV1::default(); + for (index, page) in pages.iter().enumerate() { + if let Some(exceeded) = + ledger.try_admit(page.estimated_write_rows(), page.estimated_write_bytes())? { - document_ngram_scratch(text_bytes)?.0 - } else { - 0 - }; - let term_postings = text_bytes - .checked_add(1) - .and_then(|count| count.checked_add(chunk.subtokens.len())) - .and_then(|count| count.checked_add(chunk.exact_terms.len().checked_mul(2)?)) - .ok_or_else(batch_ledger_overflow)?; - let exact_postings = chunk - .exact_terms - .len() - .checked_add(1) - .ok_or_else(batch_ledger_overflow)?; - rows = rows - .checked_add(2) - .and_then(|count| count.checked_add(term_postings)) - .and_then(|count| count.checked_add(exact_postings)) - .and_then(|count| count.checked_add(normalized_ngrams)) - .and_then(|count| count.checked_add(raw_ngrams)) - .ok_or_else(batch_ledger_overflow)?; + return Ok((index, Some(exceeded))); + } } - Ok((rows, write_bytes)) + Ok((pages.len(), None)) } /// Refuse a batch unless its retained source pages, all prepared outputs, and @@ -1805,7 +1869,7 @@ fn projected_chunk_prepared_retained_upper_bound_bytes( ) -> Result { let transient = projected_chunk_transient_bytes(metadata, admitted)?; let text_bytes = admitted.chunk().sanitized_text.as_str().len(); - let normalized_text_bytes = text_bytes.saturating_mul(3); + let normalized_text_bytes = text_bytes; let (_, normalized_scratch) = document_ngram_scratch(normalized_text_bytes)?; let (_, raw_scratch) = document_ngram_scratch(text_bytes)?; // Preparation retains `(kind, ngram)` tuples, not the `u32` scratch @@ -1903,9 +1967,10 @@ fn projected_chunk_transient_bytes( chunk.anchor.file_occurrence_id )) })?; - // Normalization can expand one Unicode scalar to at most three scalars; - // JSON can then escape each byte. This bound intentionally charges both - // cloned and moved ownership before append allocates either representation. + // Canonical lexical normalization is ASCII lowercasing, so it preserves + // the exact UTF-8 byte length. JSON escaping is charged separately below. + // The bound intentionally charges both cloned and moved ownership before + // append allocates either representation. let text_bytes = chunk.sanitized_text.as_str().len(); let subtoken_bytes = chunk .subtokens @@ -1914,12 +1979,12 @@ fn projected_chunk_transient_bytes( let exact_bytes = chunk.exact_terms.iter().fold(0usize, |total, term| { total.saturating_add(term.canonical_bytes().len()) }); - let normalized_text_bytes = text_bytes.saturating_mul(3); + let normalized_text_bytes = text_bytes; let field_text_bytes = normalized_text_bytes - .saturating_add(logical_path.len().saturating_mul(3)) - .saturating_add(subtoken_bytes.saturating_mul(3)) - .saturating_add(exact_bytes.saturating_mul(6)); - let field_entries = text_bytes + .saturating_add(logical_path.len()) + .saturating_add(subtoken_bytes) + .saturating_add(exact_bytes.saturating_mul(2)); + let field_entries = lexical_token_count(chunk.sanitized_text.as_str()) .saturating_add(1) .saturating_add(chunk.subtokens.len()) .saturating_add(chunk.exact_terms.len().saturating_mul(2)); @@ -1949,6 +2014,20 @@ fn projected_chunk_transient_bytes( .saturating_add(serialized_bytes)) } +fn lexical_token_count(value: &str) -> usize { + let mut count = 0usize; + let mut inside_token = false; + for character in value.chars() { + let accepted = + character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | ':' | '.' | '/'); + if accepted && !inside_token { + count = count.saturating_add(1); + } + inside_token = accepted; + } + count +} + fn chunk_owned_bytes(chunk: &CodeSearchChunkV1) -> usize { let subtoken_bytes = chunk.subtokens.iter().fold( chunk @@ -3874,6 +3953,27 @@ fn record_batch_receipt_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { let _ = pages; } +#[cfg(feature = "hotpath")] +fn record_batch_prefix_limit(limit: CodeLexicalArtifactBatchLimitV1) { + match limit { + CodeLexicalArtifactBatchLimitV1::Memory => { + hotpath::gauge!("query.artifact.batch.prefix_limited.memory_total").inc(1u64); + } + CodeLexicalArtifactBatchLimitV1::PreparedRows => { + hotpath::gauge!("query.artifact.batch.prefix_limited.prepared_rows_total").inc(1u64); + } + CodeLexicalArtifactBatchLimitV1::EstimatedWriteBytes => { + hotpath::gauge!("query.artifact.batch.prefix_limited.estimated_write_bytes_total") + .inc(1u64); + } + } +} + +#[cfg(not(feature = "hotpath"))] +fn record_batch_prefix_limit(limit: CodeLexicalArtifactBatchLimitV1) { + let _ = limit; +} + #[cfg(feature = "hotpath")] fn record_artifact_progress(progress: &CodeLexicalArtifactBuildProgressV1) { hotpath::gauge!("query.artifact.pages").set(progress.next_page_ordinal); @@ -4034,6 +4134,7 @@ mod tests { ledger .try_admit(*rows, *bytes) .expect("extend canonical limit ledger") + .is_none() }) .count(); assert_eq!( 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 8d3561e191..f71c4f823d 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -344,12 +344,10 @@ fn real_lexical_source_fixture() -> RealLexicalSourceFixture { /// scores tie across files without content-identical chunks. fn real_lexical_source_fixture_with_files(file_count: usize) -> RealLexicalSourceFixture { assert!(file_count >= 1, "fixture needs at least one file"); - let repository = id::("repository.artifact"); - let sanitizer_revision = id::("sanitizer.v1"); let identity_source = b"import type { Widget } from \"widget-kit\";\nexport function render(value: Widget) { return value; }\n"; - let sources: Vec<(SanitizedCodeFileV1, Vec)> = (0..file_count) + let sources = (0..file_count) .map(|ordinal| { - let (file_id, logical_path, source) = if ordinal == 0 { + if ordinal == 0 { ( "file.artifact".to_owned(), "src/artifact.ts".to_owned(), @@ -366,7 +364,21 @@ fn real_lexical_source_fixture_with_files(file_count: usize) -> RealLexicalSourc ) .into_bytes(), ) - }; + } + }) + .collect(); + real_lexical_source_fixture_from_sources(sources) +} + +fn real_lexical_source_fixture_from_sources( + source_inputs: Vec<(String, String, Vec)>, +) -> RealLexicalSourceFixture { + assert!(!source_inputs.is_empty(), "fixture needs at least one file"); + let repository = id::("repository.artifact"); + let sanitizer_revision = id::("sanitizer.v1"); + let sources = source_inputs + .into_iter() + .map(|(file_id, logical_path, source)| { let file = SanitizedCodeFileV1 { file_occurrence_id: id::(&file_id), logical_path, @@ -376,7 +388,11 @@ fn real_lexical_source_fixture_with_files(file_count: usize) -> RealLexicalSourc }; (file, source) }) - .collect(); + .collect::>(); + let identity_source = sources + .first() + .map(|(_, source)| source.as_slice()) + .expect("non-empty fixture sources"); let snapshot = SanitizedCodeSnapshotV1 { repository: repository.clone(), worktree: None, @@ -666,26 +682,12 @@ impl TestArtifactSourceStaging for CodeLexicalArtifactBuilderV1 { let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new(16, 32 * 1024 * 1024) .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; let receipt = loop { - let staged_pages = self.progress()?.next_page_ordinal; let admitted = source .next_page_batch_if(control, bounds, |pages| { - if pages - .last() - .is_some_and(|page| page.page_ordinal() < staged_pages) - { - Ok(NonZeroUsize::new(pages.len()).expect("non-empty source batch")) - } else { - let accepted = self.largest_admissible_page_prefix(pages)?; - let Some(accepted) = NonZeroUsize::new(accepted) else { - self.append_pages(&pages[..1], control)?; - return Err(CodeLexicalArtifactErrorV1::Contract( - "lexical artifact admitted no pages without returning its typed bound" - .to_owned(), - )); - }; - self.append_pages(&pages[..accepted.get()], control)?; - Ok(accepted) - } + let prepared = self.prepare_admissible_page_prefix(pages, control)?; + let accepted = prepared.accepted_prefix(); + self.append_prepared_pages(prepared.prepared_pages(), control)?; + Ok(accepted) }) .map_err(|error| match error { CodeIndexProductionErrorV1::Interrupted(interruption) => { @@ -1638,6 +1640,49 @@ fn disk_artifact_admission_keeps_real_pages_wide_until_the_actual_limit() { assert_eq!(progress.next_page_ordinal, 2); } +#[test] +fn disk_artifact_repetitive_multi_chunk_page_makes_exact_prefix_progress() { + let mut source = String::with_capacity(700_000); + source.push_str("// "); + source.push_str(&"a".repeat(650_000)); + source.push_str( + "\nexport function first() { return 1; }\nexport function second() { return 2; }\n", + ); + let fixture = real_lexical_source_fixture_from_sources(vec![( + "file.artifact.repetitive".to_owned(), + "src/repetitive.ts".to_owned(), + source.into_bytes(), + )]); + let (pages, _) = drain_verified_pages(&fixture, 128); + let page = pages.first().expect("repetitive source page"); + assert!( + page.chunks().len() > 1, + "the real parser-backed page must cover multiple chunks" + ); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("repetitive-prefix.sqlite"); + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) + .expect("create artifact"); + let prepared = builder + .prepare_admissible_page_prefix( + std::slice::from_ref(page), + &ArtifactControl { cancelled: false }, + ) + .expect("select repetitive page prefix"); + assert_eq!( + prepared.accepted_prefix().get(), + 1, + "a conservative pre-dedup estimate must not turn one valid page into a permanent zero prefix" + ); + let progress = builder + .append_prepared_pages( + prepared.prepared_pages(), + &ArtifactControl { cancelled: false }, + ) + .expect("exactly prepared repetitive page must commit inside every canonical limit"); + assert_eq!(progress.next_page_ordinal, 1); +} + #[test] fn disk_artifact_finalization_resumes_after_restart_without_source_replay() { let (mut fixture, pages, source_receipt) = real_verified_pages_with_maximum_page_chunks(1); diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 5b94c6c1e3..853c2e47a1 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -7,7 +7,7 @@ use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, fs::File, io::{Read, Write}, - num::{NonZeroU64, NonZeroUsize}, + num::NonZeroU64, panic::{AssertUnwindSafe, catch_unwind}, path::{Path, PathBuf}, sync::{ @@ -2948,24 +2948,12 @@ impl LatestCompleteCodeIndexV1 { #[cfg(feature = "hotpath")] hotpath::gauge!("query.artifact.batch.offered_pages_total") .inc(u64::try_from(pages.len()).unwrap_or(u64::MAX)); - let accepted = - NonZeroUsize::new(builder.largest_admissible_page_prefix(pages)?) - .ok_or_else(|| { - CodeLexicalArtifactErrorV1::Unreserved( - "one sealed lexical page exceeds the artifact build memory budget" - .to_owned(), - ) - })?; - let pages = &pages[..accepted.get()]; - #[cfg(feature = "hotpath")] - hotpath::gauge!("query.artifact.batch.accepted_pages_total") - .inc(u64::try_from(pages.len()).unwrap_or(u64::MAX)); - let batch_pages = u64::try_from(pages.len()).map_err(|_| { + let offered_batch_pages = u64::try_from(pages.len()).map_err(|_| { CodeLexicalArtifactErrorV1::Contract( "text-artifact batch page count exceeds u64".to_owned(), ) })?; - let batch_payload_bytes = pages.iter().try_fold(0_u64, |total, page| { + let offered_payload_bytes = pages.iter().try_fold(0_u64, |total, page| { total.checked_add(page.payload_bytes()).ok_or_else(|| { CodeLexicalArtifactErrorV1::Contract( "text-artifact batch payload bytes overflowed".to_owned(), @@ -2974,23 +2962,47 @@ impl LatestCompleteCodeIndexV1 { })?; self.publish_text_progress_phase( CodeIndexBuildPhaseV1::RelationalPreparation, - batch_pages, - batch_payload_bytes, + offered_batch_pages, + offered_payload_bytes, ); - let progress = hotpath::measure_block!("query.artifact.batch.builder", { - let prepared = builder.prepare_pages(pages, control)?; - self.publish_text_progress_phase( - CodeIndexBuildPhaseV1::BulkCommit, - batch_pages, - batch_payload_bytes, - ); - let commit_started = Instant::now(); - let progress = builder.append_prepared_pages(&prepared, control)?; - commit_latency_micros = Some( - u64::try_from(commit_started.elapsed().as_micros()).unwrap_or(u64::MAX), - ); - Ok::<_, CodeLexicalArtifactErrorV1>(progress) - })?; + let (progress, accepted) = + hotpath::measure_block!("query.artifact.batch.builder", { + let prepared = + builder.prepare_admissible_page_prefix(pages, control)?; + let accepted = prepared.accepted_prefix(); + let accepted_pages = &pages[..accepted.get()]; + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.batch.accepted_pages_total") + .inc(u64::try_from(accepted_pages.len()).unwrap_or(u64::MAX)); + let batch_pages = + u64::try_from(accepted_pages.len()).map_err(|_| { + CodeLexicalArtifactErrorV1::Contract( + "text-artifact accepted page count exceeds u64".to_owned(), + ) + })?; + let batch_payload_bytes = + accepted_pages.iter().try_fold(0_u64, |total, page| { + total.checked_add(page.payload_bytes()).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "text-artifact accepted payload bytes overflowed" + .to_owned(), + ) + }) + })?; + self.publish_text_progress_phase( + CodeIndexBuildPhaseV1::BulkCommit, + batch_pages, + batch_payload_bytes, + ); + let commit_started = Instant::now(); + let progress = builder + .append_prepared_pages(prepared.prepared_pages(), control)?; + commit_latency_micros = Some( + u64::try_from(commit_started.elapsed().as_micros()) + .unwrap_or(u64::MAX), + ); + Ok::<_, CodeLexicalArtifactErrorV1>((progress, accepted)) + })?; durable_progress = Some(progress); Ok(accepted) }) From cec6d23a824857b65b3c5cca456d43c24988548f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 03:57:26 +0000 Subject: [PATCH 25/69] feat(index): stream authenticated text metadata --- .../src/production/lexical_page_source.rs | 267 +++++++++++++++++- 1 file changed, 265 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-code-index/src/production/lexical_page_source.rs b/crates/tracedecay-code-index/src/production/lexical_page_source.rs index 3a244e1ae7..5c8dfa5a84 100644 --- a/crates/tracedecay-code-index/src/production/lexical_page_source.rs +++ b/crates/tracedecay-code-index/src/production/lexical_page_source.rs @@ -6,6 +6,8 @@ use std::{ use sha2::{Digest, Sha256}; use tracedecay_domain::ExactTechnicalTermV1; +use crate::{capabilities::expected_seal_digest, intake::INTAKE_DIGEST_SEPARATOR}; + use super::sealed_codec::{ LEGACY_CANONICAL_SEALED_GENERATION_FORMAT_REVISION, PersistedFileGenerationArtifactsV1, }; @@ -20,6 +22,7 @@ const IMPORT_DICTIONARY_CHAIN_RECORD_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-import-dictionary-chain.v1\0"; const CURSOR_DIGEST_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-cursor.v1\0"; const LAYOUT_PROGRESS_INTERVAL_BYTES: u64 = 16 * 1024 * 1024; +const MAX_LEXICAL_GENERATION_METADATA_BYTES: u64 = 64 * 1024 * 1024; type PersistedSealedLexicalCursorFields = ( String, @@ -756,12 +759,35 @@ pub struct VerifiedSealedLexicalPageSourceV1 { maximum_file_bytes: u64, source_state_digest: ManifestDigest, format_revision: u32, + metadata: VerifiedSealedTextGenerationMetadataV1, maximum_page_chunks: usize, maximum_page_bytes: usize, cursor: VerifiedSealedLexicalCursorV1, admitted_file: Option<(u64, AdmittedSealedLexicalFileV1)>, } +/// Authenticated generation metadata needed by exact and lexical serving. +/// +/// The full sealed generation can be gigabytes. This projection retains only +/// the manifest and sanitized snapshot header that precede the files array; +/// the layout scan authenticates the complete content-addressed seal before +/// this value becomes observable. +#[derive(Clone, Debug)] +pub struct VerifiedSealedTextGenerationMetadataV1 { + manifest: CodeGenerationManifestV1, + snapshot: SanitizedCodeSnapshotV1, +} + +impl VerifiedSealedTextGenerationMetadataV1 { + pub fn manifest(&self) -> &CodeGenerationManifestV1 { + &self.manifest + } + + pub fn snapshot(&self) -> &SanitizedCodeSnapshotV1 { + &self.snapshot + } +} + impl VerifiedSealedLexicalPageSourceV1 { #[hotpath::measure] pub fn open( @@ -795,6 +821,7 @@ impl VerifiedSealedLexicalPageSourceV1 { "sealed lexical files array has an invalid byte span".to_owned(), ) })?; + let metadata = read_verified_text_metadata(&mut reader, &layout, control)?; Ok(Self { reader, file_count: layout.file_count, @@ -804,6 +831,7 @@ impl VerifiedSealedLexicalPageSourceV1 { maximum_file_bytes: layout.maximum_file_bytes, source_state_digest: layout.state_digest, format_revision: layout.format_revision, + metadata, maximum_page_chunks, maximum_page_bytes, cursor, @@ -820,7 +848,7 @@ impl VerifiedSealedLexicalPageSourceV1 { /// no whole-generation `Vec` is required merely to authenticate it. #[hotpath::measure] pub fn open_content_addressed( - mut reader: R, + reader: R, admitted_len: u64, expected_file_digest: ManifestDigest, maximum_page_chunks: usize, @@ -832,11 +860,44 @@ impl VerifiedSealedLexicalPageSourceV1 { "sealed lexical page bounds must be non-zero".to_owned(), )); } - let layout = scan_layout( + Self::open_content_addressed_with_progress( + reader, + admitted_len, + expected_file_digest, + maximum_page_chunks, + maximum_page_bytes, + control, + |_, _| {}, + ) + } + + /// Open a content-addressed source while reporting authenticated scan + /// bytes. The callback is invoked at zero, bounded byte intervals, and + /// exactly once with the admitted total before metadata is exposed. + #[hotpath::measure] + pub fn open_content_addressed_with_progress( + mut reader: R, + admitted_len: u64, + expected_file_digest: ManifestDigest, + maximum_page_chunks: usize, + maximum_page_bytes: usize, + control: &dyn CodeIndexExecutionControlV1, + mut progress: F, + ) -> Result + where + F: FnMut(u64, u64), + { + if maximum_page_chunks == 0 || maximum_page_bytes == 0 { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical page bounds must be non-zero".to_owned(), + )); + } + let layout = scan_layout_with_progress( &mut reader, admitted_len, Some(&expected_file_digest), control, + &mut progress, )?; let cursor = VerifiedSealedLexicalCursorV1::initial( layout.state_digest.clone(), @@ -850,6 +911,7 @@ impl VerifiedSealedLexicalPageSourceV1 { "sealed lexical files array has an invalid byte span".to_owned(), ) })?; + let metadata = read_verified_text_metadata(&mut reader, &layout, control)?; Ok(Self { reader, file_count: layout.file_count, @@ -859,6 +921,7 @@ impl VerifiedSealedLexicalPageSourceV1 { maximum_file_bytes: layout.maximum_file_bytes, source_state_digest: layout.state_digest, format_revision: layout.format_revision, + metadata, maximum_page_chunks, maximum_page_bytes, cursor, @@ -866,6 +929,10 @@ impl VerifiedSealedLexicalPageSourceV1 { }) } + pub fn metadata(&self) -> &VerifiedSealedTextGenerationMetadataV1 { + &self.metadata + } + /// Reopen an authenticated durable source at an accepted persisted cursor. /// /// The layout scan authenticates the raw content address but does not @@ -1562,6 +1629,8 @@ struct SealedLexicalLayoutV1 { first_file_offset: u64, files_end_offset: u64, maximum_file_bytes: u64, + manifest_range: Option<(u64, u64)>, + snapshot_range: Option<(u64, u64)>, #[cfg(test)] structural_byte_visits: u64, #[cfg(test)] @@ -1574,6 +1643,22 @@ fn scan_layout( admitted_len: u64, expected_file_digest: Option<&ManifestDigest>, control: &dyn CodeIndexExecutionControlV1, +) -> Result { + scan_layout_with_progress( + reader, + admitted_len, + expected_file_digest, + control, + &mut |_, _| {}, + ) +} + +fn scan_layout_with_progress( + reader: &mut R, + admitted_len: u64, + expected_file_digest: Option<&ManifestDigest>, + control: &dyn CodeIndexExecutionControlV1, + progress: &mut dyn FnMut(u64, u64), ) -> Result { if admitted_len > MAX_SEALED_CODE_GENERATION_BYTES_V1 { return Err(CodeIndexProductionErrorV1::Contract( @@ -1586,6 +1671,7 @@ fn scan_layout( hotpath::gauge!("code_index_lexical_layout_scan_attempts").inc(1); hotpath::gauge!("code_index_lexical_layout_bytes_total").set(admitted_len); hotpath::gauge!("code_index_lexical_layout_bytes_scanned").set(0); + progress(0, admitted_len); let mut scanner = LayoutScanner::default(); let mut file_hasher = expected_file_digest.map(|_| Sha256::new()); let read_limit = admitted_len.checked_add(1).ok_or_else(|| { @@ -1637,6 +1723,7 @@ fn scan_layout( let admitted_observed = observed.min(admitted_len); if admitted_observed >= next_progress || admitted_observed == admitted_len { hotpath::gauge!("code_index_lexical_layout_bytes_scanned").set(admitted_observed); + progress(admitted_observed, admitted_len); next_progress = admitted_observed.saturating_add(LAYOUT_PROGRESS_INTERVAL_BYTES); } remaining -= read_bytes; @@ -1662,6 +1749,8 @@ enum LayoutKey { Generation, Files, FormatRevision, + Manifest, + Snapshot, } impl LayoutKey { @@ -1671,6 +1760,8 @@ impl LayoutKey { b"generation" => Some(Self::Generation), b"files" => Some(Self::Files), b"format_revision" => Some(Self::FormatRevision), + b"manifest" => Some(Self::Manifest), + b"snapshot" => Some(Self::Snapshot), _ => None, } } @@ -1698,6 +1789,9 @@ struct LayoutScanner { files_end_offset: Option, file_count: u64, maximum_file_bytes: u64, + captured_metadata_object: Option<(LayoutKey, u64, usize)>, + manifest_range: Option<(u64, u64)>, + snapshot_range: Option<(u64, u64)>, #[cfg(test)] structural_byte_visits: u64, #[cfg(test)] @@ -1728,6 +1822,9 @@ impl Default for LayoutScanner { files_end_offset: None, file_count: 0, maximum_file_bytes: 0, + captured_metadata_object: None, + manifest_range: None, + snapshot_range: None, #[cfg(test)] structural_byte_visits: 0, #[cfg(test)] @@ -1905,6 +2002,23 @@ impl LayoutScanner { self.generation_hasher = Some(Sha256::new()); event = GenerationSpanEvent::Opened; } + if matches!( + self.pending_key, + Some(LayoutKey::Manifest | LayoutKey::Snapshot) + ) && self.generation_depth == Some(self.brace_depth) + { + let key = self.pending_key.ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed text metadata key disappeared".to_owned(), + ) + })?; + if self.captured_metadata_object.is_some() { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed text metadata objects overlap".to_owned(), + )); + } + self.captured_metadata_object = Some((key, offset, self.brace_depth + 1)); + } if self.files_depth == Some(self.bracket_depth) && self.generation_depth == Some(self.brace_depth) && self.current_file_start.is_none() @@ -1915,6 +2029,25 @@ impl LayoutScanner { self.pending_key = None; } b'}' => { + if let Some((key, start, depth)) = self.captured_metadata_object + && depth == self.brace_depth + { + let end = offset.checked_add(1).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed text metadata end offset overflowed".to_owned(), + ) + })?; + match key { + LayoutKey::Manifest => self.manifest_range = Some((start, end)), + LayoutKey::Snapshot => self.snapshot_range = Some((start, end)), + _ => { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed text metadata capture has an invalid key".to_owned(), + )); + } + } + self.captured_metadata_object = None; + } if let Some(start) = self.current_file_start && self .generation_depth @@ -1992,6 +2125,7 @@ impl LayoutScanner { || self.brace_depth != 0 || self.bracket_depth != 0 || self.current_file_start.is_some() + || self.captured_metadata_object.is_some() { return Err(CodeIndexProductionErrorV1::Contract( "sealed lexical source has incomplete JSON structure".to_owned(), @@ -2037,6 +2171,8 @@ impl LayoutScanner { first_file_offset, files_end_offset, maximum_file_bytes: self.maximum_file_bytes, + manifest_range: self.manifest_range, + snapshot_range: self.snapshot_range, #[cfg(test)] structural_byte_visits: self.structural_byte_visits, #[cfg(test)] @@ -2079,6 +2215,93 @@ fn first_json_string_control(bytes: &[u8]) -> Option { .map(|relative| tail_base + relative) } +fn read_verified_text_metadata( + reader: &mut R, + layout: &SealedLexicalLayoutV1, + control: &dyn CodeIndexExecutionControlV1, +) -> Result { + #[hotpath::measure] + fn decode_range( + reader: &mut R, + range: (u64, u64), + label: &'static str, + control: &dyn CodeIndexExecutionControlV1, + ) -> Result { + checkpoint(control)?; + let length = range.1.checked_sub(range.0).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata range is invalid" + )) + })?; + if length == 0 || length > MAX_LEXICAL_GENERATION_METADATA_BYTES { + return Err(CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata exceeds its byte bound" + ))); + } + let length = usize::try_from(length).map_err(|_| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata exceeds the platform limit" + )) + })?; + reader.seek(SeekFrom::Start(range.0)).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata seek failed: {error}" + )) + })?; + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata read failed: {error}" + )) + })?; + checkpoint(control)?; + serde_json::from_slice(&bytes).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed {label} metadata decoding failed: {error}" + )) + }) + } + + let manifest: CodeGenerationManifestV1 = decode_range( + reader, + layout.manifest_range.ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed generation manifest metadata is missing".to_owned(), + ) + })?, + "manifest", + control, + )?; + let snapshot: SanitizedCodeSnapshotV1 = decode_range( + reader, + layout.snapshot_range.ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed generation snapshot metadata is missing".to_owned(), + ) + })?, + "snapshot", + control, + )?; + snapshot + .validate() + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; + let snapshot_digest = canonical_sha256(&(INTAKE_DIGEST_SEPARATOR, &snapshot)) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; + if snapshot_digest != manifest.snapshot_digest { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed text metadata snapshot digest does not match the manifest".to_owned(), + )); + } + let seal_digest = expected_seal_digest(&manifest) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; + if seal_digest != manifest.seal.expected_digest { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed text metadata manifest seal is invalid".to_owned(), + )); + } + Ok(VerifiedSealedTextGenerationMetadataV1 { manifest, snapshot }) +} + fn read_next_file_bytes( reader: &mut R, file_offset: u64, @@ -2460,6 +2683,46 @@ mod lexical_page_source_tests { fixture_for_source(BATCH_FIXTURE_SOURCE) } + #[test] + fn content_addressed_open_reports_authenticated_scan_progress_and_text_metadata() { + let fixture = fixture(); + let file_digest = ManifestDigest::from_sha256_bytes(&Sha256::digest(&fixture.sealed)) + .expect("fixture file digest is canonical"); + let mut progress = Vec::new(); + let source = VerifiedSealedLexicalPageSourceV1::open_content_addressed_with_progress( + Cursor::new(fixture.sealed.clone()), + u64::try_from(fixture.sealed.len()).expect("fixture length fits u64"), + file_digest, + 1, + 1024 * 1024, + &ActiveControl, + |scanned, total| progress.push((scanned, total)), + ) + .expect("authenticated source opens with progress"); + + assert_eq!(progress.first(), Some(&(0, fixture.sealed.len() as u64))); + assert_eq!( + progress.last(), + Some(&(fixture.sealed.len() as u64, fixture.sealed.len() as u64)) + ); + assert_eq!( + source.metadata().snapshot().repository.as_str(), + "repository.lexical-page-batch" + ); + assert_eq!( + source.metadata().snapshot().files[0].logical_path, + "src/batch_fixture.rs" + ); + assert_eq!( + source.metadata().manifest().project_id.as_str(), + "project.lexical-page-batch" + ); + assert_eq!( + source.metadata().manifest().privacy_domain.as_str(), + "privacy.lexical-page-batch" + ); + } + fn fixture_for_source(source: &str) -> SealedSourceFixture { let source = source.as_bytes(); let file = SanitizedCodeFileV1 { From 74590227d7a5e7dd5d5e66cd95867a1d34b3544c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 04:05:56 +0000 Subject: [PATCH 26/69] perf(index): coalesce sealed generation writes --- .../src/production/sealed_codec.rs | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay-code-index/src/production/sealed_codec.rs b/crates/tracedecay-code-index/src/production/sealed_codec.rs index df99e71104..109019deda 100644 --- a/crates/tracedecay-code-index/src/production/sealed_codec.rs +++ b/crates/tracedecay-code-index/src/production/sealed_codec.rs @@ -1,4 +1,4 @@ -use std::io::{Read, Seek, SeekFrom, Write}; +use std::io::{BufWriter, Read, Seek, SeekFrom, Write}; #[cfg(test)] use serde::de::DeserializeOwned; @@ -202,7 +202,7 @@ fn admit_sealed_generation_bytes( Ok(bytes) } -const SEALED_GENERATION_WRITE_CHUNK_BYTES_V1: usize = 64 * 1024; +const SEALED_GENERATION_WRITE_CHUNK_BYTES_V1: usize = 1024 * 1024; struct BoundedChunkWriterV1<'a, W> { writer: &'a mut W, @@ -296,9 +296,10 @@ fn write_generation_envelope_with_limits( "sealed generation writer position failed: {error}" )) })?; + let mut writer = BufWriter::with_capacity(maximum_write, writer); let (digest_start, digest_end, generation_hash, written) = { let mut bounded = BoundedChunkWriterV1 { - writer, + writer: &mut writer, written: 0, byte_limit, maximum_write, @@ -366,6 +367,11 @@ fn write_generation_envelope_with_limits( )) } })?; + bounded.flush().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation serialization flush failed: {error}" + )) + })?; (digest_start, digest_end, generation_hash, bounded.written) }; @@ -396,11 +402,16 @@ fn write_generation_envelope_with_limits( "sealed generation digest seek failed: {error}" )) })?; - write_chunked(writer, &digest_bytes, maximum_write).map_err(|error| { + write_chunked(&mut writer, &digest_bytes, maximum_write).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "sealed generation digest serialization failed: {error}" )) })?; + writer.flush().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation digest flush failed: {error}" + )) + })?; let envelope_end = envelope_start.checked_add(written).ok_or_else(|| { CodeIndexProductionErrorV1::Contract( "sealed generation writer position overflowed".to_owned(), @@ -413,6 +424,11 @@ fn write_generation_envelope_with_limits( "sealed generation final seek failed: {error}" )) })?; + writer.flush().map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed generation final flush failed: {error}" + )) + })?; Ok(written) } @@ -755,6 +771,8 @@ mod tests { struct MaximumWriteSink { inner: std::io::Cursor>, maximum_write: usize, + write_calls: usize, + largest_write: usize, } impl Write for MaximumWriteSink { @@ -762,6 +780,8 @@ mod tests { if bytes.len() > self.maximum_write { return Err(std::io::Error::other("write exceeded the fixture bound")); } + self.write_calls += 1; + self.largest_write = self.largest_write.max(bytes.len()); self.inner.write(bytes) } @@ -791,6 +811,8 @@ mod tests { let mut assembled = MaximumWriteSink { inner: std::io::Cursor::new(Vec::new()), maximum_write: 7, + write_calls: 0, + largest_write: 0, }; write_generation_envelope_with_limits(&generation, &mut assembled, u64::MAX, 7) .expect("direct sealed envelope encoding"); @@ -808,6 +830,31 @@ mod tests { assert_eq!(assembled, prior); } + #[test] + fn direct_envelope_encoding_coalesces_small_serialization_writes() { + const WRITE_BOUND: usize = 1024 * 1024; + let generation = serde_json::json!({ + "format_revision": SEALED_GENERATION_FORMAT_REVISION_V1, + "payload": vec![1_u8; WRITE_BOUND] + }); + let mut assembled = MaximumWriteSink { + inner: std::io::Cursor::new(Vec::new()), + maximum_write: WRITE_BOUND, + write_calls: 0, + largest_write: 0, + }; + + write_generation_envelope_with_limits(&generation, &mut assembled, u64::MAX, WRITE_BOUND) + .expect("direct sealed envelope encoding"); + + assert!( + assembled.write_calls <= 8, + "a two-megabyte seal must use coalesced writes, observed {}", + assembled.write_calls + ); + assert!(assembled.largest_write <= WRITE_BOUND); + } + #[test] fn direct_envelope_encoding_refuses_before_exceeding_its_byte_limit() { let generation = serde_json::json!({ @@ -827,6 +874,8 @@ mod tests { let mut refused = MaximumWriteSink { inner: std::io::Cursor::new(Vec::new()), maximum_write: 7, + write_calls: 0, + largest_write: 0, }; let error = write_generation_envelope_with_limits(&generation, &mut refused, byte_limit, 7) From d8083a92052f7d6053dbbd306b3262642500f28a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 04:09:32 +0000 Subject: [PATCH 27/69] perf(hotpath): label code-index admission and extraction phases --- crates/tracedecay-code-index/src/extract.rs | 6 ++-- .../tracedecay-code-index/src/generations.rs | 6 ++-- .../src/hotpath_observe.rs | 30 +++++++++++++++++++ crates/tracedecay-code-index/src/intake.rs | 14 ++++++--- .../src/production/generation_statistics.rs | 1 + .../src/production/parser_artifacts.rs | 2 +- .../src/retained_parse.rs | 13 ++++++-- .../tracedecay-code-index/src/source_walk.rs | 2 +- 8 files changed, 60 insertions(+), 14 deletions(-) diff --git a/crates/tracedecay-code-index/src/extract.rs b/crates/tracedecay-code-index/src/extract.rs index d965fef6dd..8e6763fb1a 100644 --- a/crates/tracedecay-code-index/src/extract.rs +++ b/crates/tracedecay-code-index/src/extract.rs @@ -199,7 +199,7 @@ impl TreeSitterExtractor { }) } - #[hotpath::measure] + #[hotpath::measure(label = "code_index.extract.incremental")] pub(crate) fn extract_preparsed( &self, file: &ReceiptBoundCodeFileV1, @@ -213,6 +213,7 @@ impl TreeSitterExtractor { } let authority = file.authority().clone(); let file = file.validated_file(); + crate::hotpath_observe::record_captured_bytes(file.sanitized_bytes.len() as u64); validate_descriptor(file, descriptor)?; let admitted_prefix = file .sanitized_bytes @@ -497,7 +498,7 @@ pub(crate) fn parser_import_rows_digest( } impl LanguageExtractor for TreeSitterExtractor { - #[hotpath::measure] + #[hotpath::measure(label = "code_index.extract.full")] fn extract( &self, file: &ReceiptBoundCodeFileV1, @@ -509,6 +510,7 @@ impl LanguageExtractor for TreeSitterExtractor { } let authority = file.authority().clone(); let file = file.validated_file(); + crate::hotpath_observe::record_captured_bytes(file.sanitized_bytes.len() as u64); validate_descriptor(file, descriptor)?; let parser = self.resolve_parser(file, descriptor).ok_or({ diff --git a/crates/tracedecay-code-index/src/generations.rs b/crates/tracedecay-code-index/src/generations.rs index 334c8963fa..7efa3d6bc9 100644 --- a/crates/tracedecay-code-index/src/generations.rs +++ b/crates/tracedecay-code-index/src/generations.rs @@ -270,7 +270,7 @@ impl GenerationPlanner { /// Plan and seal one immutable generation while binding every inferred and /// explicitly declared rebuild cause into its identity and publication /// fence. - #[hotpath::measure] + #[hotpath::measure(label = "code_index.build.plan_full")] pub fn plan_generation_with_invalidation( &self, snapshot: &ValidatedCodeSnapshotV1, @@ -364,7 +364,7 @@ impl GenerationPlanner { /// quarantined corruption are not inferable from a sanitized snapshot, /// so callers must declare them explicitly. Declared reasons are merged /// with descriptor, sanitizer, chunker, and privacy incompatibilities. - #[hotpath::measure] + #[hotpath::measure(label = "code_index.build.plan_increment")] pub fn plan_increment_with_invalidation( &self, prior_manifest: &CodeGenerationManifestV1, @@ -681,7 +681,7 @@ pub enum GenerationJoinErrorV1 { /// every eligible chunk names exactly one code generation and file /// occurrence). Cross-generation documents or chunks, undeclared chunks, and /// duplicates are typed rejections — never silently joined. -#[hotpath::measure] +#[hotpath::measure(label = "code_index.build.generation_join")] pub fn join_chunks_to_generation( generation: &CodeGenerationManifestV1, document: &CodeSearchDocumentV1, diff --git a/crates/tracedecay-code-index/src/hotpath_observe.rs b/crates/tracedecay-code-index/src/hotpath_observe.rs index ad8757f2e2..efa49623ca 100644 --- a/crates/tracedecay-code-index/src/hotpath_observe.rs +++ b/crates/tracedecay-code-index/src/hotpath_observe.rs @@ -225,6 +225,36 @@ pub(crate) fn record_files(count: usize) { } } +/// Record the bounded candidate byte total admitted to one parser or builder +/// operation. The key is static and the value is a current total rather than +/// a per-file series, so a large generation cannot create profiler entries. +#[inline(always)] +pub(crate) fn record_candidate_bytes(bytes: u64) { + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("code_index_candidate_bytes").set(bytes); + } + #[cfg(not(feature = "hotpath"))] + { + let _ = bytes; + } +} + +/// Record the bounded sanitized bytes captured for the current source +/// operation. This is deliberately a gauge: it reports the latest admitted +/// boundary without accumulating one event per file. +#[inline(always)] +pub(crate) fn record_captured_bytes(bytes: u64) { + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("code_index_captured_bytes").set(bytes); + } + #[cfg(not(feature = "hotpath"))] + { + let _ = bytes; + } +} + /// Every call site computes its byte total inside a `hotpath`-gated block, so /// this carries the same gate instead of a body that can never run. #[cfg(feature = "hotpath")] diff --git a/crates/tracedecay-code-index/src/intake.rs b/crates/tracedecay-code-index/src/intake.rs index 7100483c71..8ee22ecfbe 100644 --- a/crates/tracedecay-code-index/src/intake.rs +++ b/crates/tracedecay-code-index/src/intake.rs @@ -253,18 +253,24 @@ impl CodeIndexIntake for SanitizedCodeIntake { &self, snapshot: SanitizedCodeSnapshotV1, ) -> Result { - self.validate_snapshot(snapshot) + hotpath::measure_block!( + "code_index.intake.validation", + self.validate_snapshot(snapshot) + ) } fn admit( &self, snapshot: SanitizedCodeSnapshotV1, ) -> Result { - self.validate_snapshot(snapshot) - .map(SanitizedSnapshotCapabilityV1::new) + hotpath::measure_block!( + "code_index.intake.admission", + self.validate_snapshot(snapshot) + .map(SanitizedSnapshotCapabilityV1::new) + ) } - #[hotpath::measure] + #[hotpath::measure(label = "code_index.intake.bind_file")] fn bind_file( &self, capability: &SanitizedSnapshotCapabilityV1, diff --git a/crates/tracedecay-code-index/src/production/generation_statistics.rs b/crates/tracedecay-code-index/src/production/generation_statistics.rs index e6d3c7ff16..bc86ca2027 100644 --- a/crates/tracedecay-code-index/src/production/generation_statistics.rs +++ b/crates/tracedecay-code-index/src/production/generation_statistics.rs @@ -24,6 +24,7 @@ impl CodeIndexPublishedGenerationV1 { /// including parsed, error, and unsupported spans. Keeping the checked /// accumulation here makes a census faithful to the sealed generation and /// prevents downstream runtime telemetry from reading removed SQL tables. + #[hotpath::measure(label = "code_index.build.statistics")] pub fn generation_statistics( &self, ) -> Result { diff --git a/crates/tracedecay-code-index/src/production/parser_artifacts.rs b/crates/tracedecay-code-index/src/production/parser_artifacts.rs index e826b007be..284dcf6f34 100644 --- a/crates/tracedecay-code-index/src/production/parser_artifacts.rs +++ b/crates/tracedecay-code-index/src/production/parser_artifacts.rs @@ -11,7 +11,7 @@ use super::{ CodeIndexRepositoryParseIdentityV1, }; -#[hotpath::measure] +#[hotpath::measure(label = "code_index.extract.parser_artifact")] pub(super) fn parse_for_indexing( retained_parses: &SharedRetainedParsePool, config: &CodeIndexProductionConfigV1, diff --git a/crates/tracedecay-code-index/src/retained_parse.rs b/crates/tracedecay-code-index/src/retained_parse.rs index f9563cb0d0..9dde0a7687 100644 --- a/crates/tracedecay-code-index/src/retained_parse.rs +++ b/crates/tracedecay-code-index/src/retained_parse.rs @@ -171,7 +171,7 @@ impl SharedRetainedParsePool { .map(|(report, _)| report) } - #[hotpath::measure] + #[hotpath::measure(label = "code_index.collect.retained")] pub fn parse_and_extract( &self, identity: ParseDocumentIdentity, @@ -195,7 +195,7 @@ impl SharedRetainedParsePool { /// The retained artifact, including import bindings, is the previous-state /// authority for incremental merging; this path never acquires a second /// parser. - #[hotpath::measure] + #[hotpath::measure(label = "code_index.collect.retained_artifact")] pub fn parse_and_extract_artifact( &self, identity: ParseDocumentIdentity, @@ -219,7 +219,7 @@ impl SharedRetainedParsePool { } } - #[hotpath::measure] + #[hotpath::measure(label = "code_index.collect.parse")] fn parse_internal( &self, identity: ParseDocumentIdentity, @@ -562,12 +562,18 @@ fn record_success( match extraction.disposition { ParsedExtractionDisposition::FullDocument => { stats.full_extractions = stats.full_extractions.saturating_add(1); + #[cfg(feature = "hotpath")] + hotpath::gauge!("code_index.collect.full_extraction_total").inc(1_u64); } ParsedExtractionDisposition::ChangedRegions => { stats.incremental_extractions = stats.incremental_extractions.saturating_add(1); + #[cfg(feature = "hotpath")] + hotpath::gauge!("code_index.collect.incremental_extraction_total").inc(1_u64); } ParsedExtractionDisposition::Reset { .. } => { stats.reset_extractions = stats.reset_extractions.saturating_add(1); + #[cfg(feature = "hotpath")] + hotpath::gauge!("code_index.collect.reset_extraction_total").inc(1_u64); } } stats.visited_top_level_nodes = stats @@ -577,6 +583,7 @@ fn record_success( .extracted_bytes .saturating_add(extraction.metrics.visited_bytes as u64); } + crate::hotpath_observe::record_candidate_bytes(report.metrics.source_bytes as u64); crate::hotpath_observe::add_parse_bytes(report.metrics.source_bytes as u64); if matches!(report.reuse, ParseReuse::Noop | ParseReuse::Incremental) { crate::hotpath_observe::add_reused_parses(1); diff --git a/crates/tracedecay-code-index/src/source_walk.rs b/crates/tracedecay-code-index/src/source_walk.rs index fbec35e1b0..2cc6cbdc23 100644 --- a/crates/tracedecay-code-index/src/source_walk.rs +++ b/crates/tracedecay-code-index/src/source_walk.rs @@ -80,7 +80,7 @@ impl GeneratedDirScope { } } -#[hotpath::measure] +#[hotpath::measure(label = "code_index.capture.source_walk")] pub fn source_walk(project_root: &Path, path_glob: Option<&str>) -> Result { let overrides = build_overrides(project_root, path_glob)?; let has_positive_override = overrides From 9810e205083cb7d5db81b06cac9ed4ae8d51f953 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 04:18:41 +0000 Subject: [PATCH 28/69] perf(privacy): batch credential keyword gates --- Cargo.lock | 1 + crates/tracedecay-runtime-core/Cargo.toml | 1 + .../src/privacy/detect.rs | 22 +- .../src/privacy/rules.rs | 188 ++++++++++++++++-- .../src/privacy/structured_text.rs | 15 +- 5 files changed, 196 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80154a2b28..38d6c405c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6361,6 +6361,7 @@ dependencies = [ name = "tracedecay-runtime-core" version = "0.1.0" dependencies = [ + "aho-corasick", "amari-holographic", "chrono", "criterion", diff --git a/crates/tracedecay-runtime-core/Cargo.toml b/crates/tracedecay-runtime-core/Cargo.toml index b13a7311a6..007a2d1a41 100644 --- a/crates/tracedecay-runtime-core/Cargo.toml +++ b/crates/tracedecay-runtime-core/Cargo.toml @@ -54,6 +54,7 @@ test-transport = ["tracedecay-rusqlite-runtime/test-transport"] test-helpers = [] [dependencies] +aho-corasick = "1.1.5" hotpath.workspace = true tracedecay-automation = { path = "../tracedecay-automation", version = "0.1.0" } tracedecay-capture = { path = "../tracedecay-capture", version = "0.1.0" } diff --git a/crates/tracedecay-runtime-core/src/privacy/detect.rs b/crates/tracedecay-runtime-core/src/privacy/detect.rs index 227f00eaae..2c0350b3fa 100644 --- a/crates/tracedecay-runtime-core/src/privacy/detect.rs +++ b/crates/tracedecay-runtime-core/src/privacy/detect.rs @@ -14,12 +14,12 @@ use super::assessment::{ validate_assessment, }; use super::detector_kernel::{ - CredentialPattern, CredentialPatternKind, CredentialPatternProfile, CredentialRuleSetError, - JsonPathSegment, JsonVisitMut, NormalizedSensitiveKey, SensitiveKeyPolicy, - compile_credential_patterns, entropy_bits_per_mille, high_entropy_ranges, - visit_json_object_keys, visit_sensitive_json_mut, + CredentialPatternKind, CredentialPatternProfile, CredentialRuleSetError, JsonPathSegment, + JsonVisitMut, NormalizedSensitiveKey, SensitiveKeyPolicy, entropy_bits_per_mille, + high_entropy_ranges, visit_json_object_keys, visit_sensitive_json_mut, }; use super::length_prefixed_sha256_hex; +use super::rules::{CredentialPatternSet, compile_credential_pattern_set}; const REDACTED_EXACT: &str = "[TraceDecay redacted: exact credential]"; const REDACTED_BEARER: &str = "[TraceDecay redacted: bearer token]"; @@ -679,12 +679,12 @@ fn memory_fact_receipt( pub(super) fn redact_text( text: &mut String, path: &str, - patterns: &[CredentialPattern], + patterns: &CredentialPatternSet, findings: &mut Vec, action: SanitizationActionV1, ) -> bool { let mut candidates = Vec::new(); - for pattern in patterns { + for (pattern, keywords_present) in patterns.iter().zip(patterns.keyword_presence(text)) { let (detector, confidence, replacement) = pattern_metadata(pattern.kind()); let priority = match pattern.kind() { CredentialPatternKind::PrivateKey => 4, @@ -693,7 +693,7 @@ pub(super) fn redact_text( }; candidates.extend( pattern - .ranges(text) + .ranges_when_keywords_present(text, keywords_present) .into_iter() .map(|range| (range, detector, confidence, replacement, priority)), ); @@ -770,12 +770,12 @@ pub(super) fn redact_text( changed } -pub(super) fn credential_patterns() -> Result<&'static [CredentialPattern], DetectionError> { - static PATTERNS: OnceLock, CredentialRuleSetError>> = +pub(super) fn credential_patterns() -> Result<&'static CredentialPatternSet, DetectionError> { + static PATTERNS: OnceLock> = OnceLock::new(); PATTERNS - .get_or_init(|| compile_credential_patterns(CredentialPatternProfile::Observation)) - .as_deref() + .get_or_init(|| compile_credential_pattern_set(CredentialPatternProfile::Observation)) + .as_ref() .map_err(|_| DetectionError::Initialization) } diff --git a/crates/tracedecay-runtime-core/src/privacy/rules.rs b/crates/tracedecay-runtime-core/src/privacy/rules.rs index 7d09a36d77..8a3d7b6a19 100644 --- a/crates/tracedecay-runtime-core/src/privacy/rules.rs +++ b/crates/tracedecay-runtime-core/src/privacy/rules.rs @@ -23,8 +23,9 @@ use std::borrow::Cow; use std::collections::BTreeSet; -use std::ops::Range; +use std::ops::{Deref, Range}; +use aho_corasick::{AhoCorasick, AhoCorasickBuilder}; use regex::{Captures, Match, Regex}; use serde::Deserialize; use thiserror::Error; @@ -119,6 +120,118 @@ pub(crate) enum CredentialRuleSetError { rule_id: String, reason: &'static str, }, + #[error("credential keyword matcher could not be compiled")] + KeywordMatcher, +} + +/// One compiled ruleset and its shared ASCII-case-insensitive keyword gate. +/// +/// The catalogue evaluates every rule against the same source value. Compiling +/// all keyword preconditions into one automaton makes that a single source +/// scan, while the ordered pattern slice remains the only authority for rule +/// precedence and redaction decisions. +pub(crate) struct CredentialPatternSet { + patterns: Vec, + keyword_matcher: KeywordMatcher, +} + +impl CredentialPatternSet { + fn new(patterns: Vec) -> Result { + let keyword_matcher = KeywordMatcher::from_patterns(&patterns)?; + Ok(Self { + patterns, + keyword_matcher, + }) + } + + pub(crate) fn keyword_presence(&self, text: &str) -> Vec { + self.keyword_matcher.presence(text, &self.patterns) + } + + #[cfg(test)] + fn unique_keyword_count(&self) -> usize { + self.keyword_matcher.pattern_count() + } +} + +impl Deref for CredentialPatternSet { + type Target = [CredentialPattern]; + + fn deref(&self) -> &Self::Target { + &self.patterns + } +} + +/// A single Aho–Corasick scan maps every keyword hit back to the rules whose +/// precondition it satisfies. `None` is the truthful empty-keyword case: those +/// rules all run and no matcher needs to scan the source. +struct KeywordMatcher { + matcher: Option, + keyword_rules: Vec>, +} + +impl KeywordMatcher { + fn from_patterns(patterns: &[CredentialPattern]) -> Result { + let mut keyword_rules = BTreeSet::new(); + for pattern in patterns { + keyword_rules.extend(pattern.keywords.iter().cloned()); + } + let keywords = keyword_rules.into_iter().collect::>(); + let mut rules_by_keyword = vec![Vec::new(); keywords.len()]; + for (rule_index, pattern) in patterns.iter().enumerate() { + for keyword in &pattern.keywords { + let keyword_index = keywords + .binary_search(keyword) + .map_err(|_| CredentialRuleSetError::KeywordMatcher)?; + rules_by_keyword[keyword_index].push(rule_index); + } + } + + let matcher = if keywords.is_empty() { + None + } else { + let mut builder = AhoCorasickBuilder::new(); + builder.ascii_case_insensitive(true); + Some( + builder + .build(&keywords) + .map_err(|_| CredentialRuleSetError::KeywordMatcher)?, + ) + }; + Ok(Self { + matcher, + keyword_rules: rules_by_keyword, + }) + } + + fn presence(&self, text: &str, patterns: &[CredentialPattern]) -> Vec { + let mut presence = patterns + .iter() + .map(|pattern| pattern.keywords.is_empty()) + .collect::>(); + let mut remaining = presence.iter().filter(|present| !**present).count(); + let Some(matcher) = &self.matcher else { + return presence; + }; + + for found in matcher.find_overlapping_iter(text.as_bytes()) { + for &rule_index in &self.keyword_rules[found.pattern().as_usize()] { + if !presence[rule_index] { + presence[rule_index] = true; + remaining -= 1; + } + } + if remaining == 0 { + break; + } + } + presence + } + + #[cfg(test)] + fn pattern_count(&self) -> usize { + self.keyword_rules.len() + } } /// One compiled credential rule. @@ -165,10 +278,13 @@ impl CredentialPattern { // Keyword gate first, exactly as upstream orders it: it is both the // rule's precondition and the cheapest possible reject, which is what // keeps a 200-rule catalogue affordable inline at ingest. - if !self.keywords_present(text) || !self.regex.is_match(text) { + let keywords_present = self.keywords_present(text); + if !keywords_present || !self.regex.is_match(text) { return false; } - !self.ranges(text).is_empty() + !self + .ranges_when_keywords_present(text, keywords_present) + .is_empty() } fn keywords_present(&self, text: &str) -> bool { @@ -179,17 +295,22 @@ impl CredentialPattern { .any(|keyword| contains_ignore_ascii_case(text, keyword)) } - /// Byte ranges to redact. The whole match, not just the secret group: a - /// sanitizer must not leave secret bytes behind, and the context a rule - /// matched on is itself worth removing. + /// Test-only direct range surface. The whole match, not just the secret + /// group: a sanitizer must not leave secret bytes behind, and the context + /// a rule matched on is itself worth removing. + #[cfg(test)] pub fn ranges(&self, text: &str) -> Vec> { - // Same keyword gate as `is_match`, and for the same reason: some - // rules (`sourcegraph-access-token` among them) accept a bare match - // that is only safe when their keyword precondition holds. - // `redact_text` calls `ranges` directly, bypassing `is_match`, so the - // gate has to live here too or a keyword-gated rule fires unguarded - // on every caller that doesn't separately check `is_match` first. - if !self.keywords_present(text) { + // Some rules (`sourcegraph-access-token`) accept a bare match that is + // safe only when their keyword precondition holds. + self.ranges_when_keywords_present(text, self.keywords_present(text)) + } + + pub(crate) fn ranges_when_keywords_present( + &self, + text: &str, + keywords_present: bool, + ) -> Vec> { + if !keywords_present { return Vec::new(); } if let Some(min_len) = self.assignment_min_len { @@ -342,6 +463,12 @@ pub(crate) fn compile_credential_patterns( Ok(patterns) } +pub(crate) fn compile_credential_pattern_set( + profile: CredentialPatternProfile, +) -> Result { + CredentialPatternSet::new(compile_credential_patterns(profile)?) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RuleOrigin { /// Upstream schema: kind is inferred, every rule runs in every profile. @@ -1018,6 +1145,10 @@ mod tests { compile_credential_patterns(profile).expect("credential ruleset compiles") } + fn pattern_set(profile: CredentialPatternProfile) -> CredentialPatternSet { + compile_credential_pattern_set(profile).expect("credential ruleset compiles") + } + fn rule<'a>(patterns: &'a [CredentialPattern], id: &str) -> &'a CredentialPattern { patterns .iter() @@ -1308,6 +1439,37 @@ mod tests { ); } + #[test] + fn combined_keyword_matcher_preserves_ascii_gate_parity_on_overlap() { + let compiled = pattern_set(CredentialPatternProfile::Observation); + let source = "sourcegraph sourceGRAPH sgP_\u{212A}ey"; + + let expected = compiled + .iter() + .map(|pattern| pattern.keywords_present(&source)) + .collect::>(); + + assert_eq!(compiled.keyword_presence(&source), expected); + } + + #[test] + fn combined_keyword_matcher_uses_one_authority_for_a_large_no_match_source() { + let compiled = pattern_set(CredentialPatternProfile::Observation); + let source = "\0".repeat(8 * 1024 * 1024); + + let expected = compiled + .iter() + .map(|pattern| pattern.keywords.is_empty()) + .collect::>(); + + assert_eq!(compiled.keyword_presence(&source), expected); + assert_eq!( + compiled.keyword_matcher.pattern_count(), + compiled.unique_keyword_count(), + "one combined matcher must own every distinct keyword" + ); + } + /// `regexTarget` steers the allowlist regexes only. A stopword read from the /// match instead of the secret lets the very keyword that triggered a rule /// excuse it — "auth" is both this rule's trigger and one of its stopwords. diff --git a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs index 3266a0b819..30dd5f8f8f 100644 --- a/crates/tracedecay-runtime-core/src/privacy/structured_text.rs +++ b/crates/tracedecay-runtime-core/src/privacy/structured_text.rs @@ -33,8 +33,9 @@ use super::detect::{ SanitizationActionV1, SanitizationDetectorOriginV1, SanitizationFindingV1, credential_patterns, redact_text, }; -use super::detector_kernel::{CredentialPattern, NormalizedSensitiveKey, SensitiveKeyPolicy}; +use super::detector_kernel::{NormalizedSensitiveKey, SensitiveKeyPolicy}; use super::length_prefixed_sha256_hex; +use super::rules::CredentialPatternSet; use super::structured::{ ParsedStructuredTextV1, StructuredSanitizationError, StructuredSanitizationLimits, StructuredTextFieldV1, StructuredTextFormatV1, StructuredTextParseFailureV1, @@ -265,7 +266,7 @@ pub(crate) fn sanitize_structured_text( }) } -fn raw_only(raw: &str, patterns: &[CredentialPattern]) -> StructuredTextSanitizationV1 { +fn raw_only(raw: &str, patterns: &CredentialPatternSet) -> StructuredTextSanitizationV1 { let mut sanitized_text = raw.to_owned(); let mut findings = Vec::new(); redact_text( @@ -290,7 +291,7 @@ fn raw_only(raw: &str, patterns: &[CredentialPattern]) -> StructuredTextSanitiza /// then emit a typed quarantine finding so every durable caller rejects it. fn quarantined_structured_text( raw: &str, - patterns: &[CredentialPattern], + patterns: &CredentialPatternSet, ) -> StructuredTextSanitizationV1 { let mut sanitized = raw_only(raw, patterns); sanitized @@ -329,7 +330,7 @@ fn line_candidates( raw: &str, fields: &[StructuredTextFieldV1], policy: &ConfiguredSensitiveKeyPolicy<'_>, - patterns: &[CredentialPattern], + patterns: &CredentialPatternSet, ) -> Vec { let mut candidates = Vec::new(); for field in fields { @@ -366,7 +367,7 @@ fn line_candidates( /// Detects whether an already-decoded value carries a credential the encoded /// bytes hid. `Authorization=Bearer%20…` only looks like a bearer token once /// the percent escapes are resolved. -fn trips_a_detector(decoded: &str, patterns: &[CredentialPattern]) -> bool { +fn trips_a_detector(decoded: &str, patterns: &CredentialPatternSet) -> bool { let mut probe = decoded.to_owned(); let mut ignored = Vec::new(); redact_text( @@ -382,7 +383,7 @@ fn tree_candidates( raw: &str, parsed: &ParsedStructuredTextV1, policy: &ConfiguredSensitiveKeyPolicy<'_>, - patterns: &[CredentialPattern], + patterns: &CredentialPatternSet, quarantine_findings: &mut Vec, ) -> Vec { let mut sensitive = Vec::new(); @@ -422,7 +423,7 @@ fn tree_candidates( fn collect_tree_fields( value: &Value, policy: &ConfiguredSensitiveKeyPolicy<'_>, - patterns: &[CredentialPattern], + patterns: &CredentialPatternSet, sensitive: &mut Vec<(String, String, SanitizationDetectorOriginV1)>, quarantine_findings: &mut Vec, ) { From 272f40d46d28eda5bcf7fd949cf7aff9b98f96ca Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 04:19:46 +0000 Subject: [PATCH 29/69] perf(graph): amortize bounded replay serialization --- crates/tracedecay-graph-db/src/generation.rs | 112 +++++++++++++++++-- 1 file changed, 104 insertions(+), 8 deletions(-) diff --git a/crates/tracedecay-graph-db/src/generation.rs b/crates/tracedecay-graph-db/src/generation.rs index 2d7248a6ef..27b91098f0 100644 --- a/crates/tracedecay-graph-db/src/generation.rs +++ b/crates/tracedecay-graph-db/src/generation.rs @@ -23,6 +23,7 @@ use crate::{ }; const DIGEST_CHECK_INTERVAL_BYTES: u64 = 64 * 1024; +const CHECKED_VEC_INITIAL_CAPACITY_BYTES: usize = 1_024; #[path = "generation/identity.rs"] mod identity; @@ -937,6 +938,8 @@ struct CheckedVecWriter<'a> { max_bytes: usize, check: &'a dyn Fn() -> Result<(), GraphDbError>, failure: Option, + #[cfg(test)] + allocation_growths: usize, } impl<'a> CheckedVecWriter<'a> { @@ -951,6 +954,8 @@ impl<'a> CheckedVecWriter<'a> { max_bytes, check, failure: None, + #[cfg(test)] + allocation_growths: 0, }) } @@ -961,6 +966,11 @@ impl<'a> CheckedVecWriter<'a> { (self.check)()?; Ok(self.bytes) } + + #[cfg(test)] + fn allocation_growths(&self) -> usize { + self.allocation_growths + } } impl Write for CheckedVecWriter<'_> { @@ -975,14 +985,38 @@ impl Write for CheckedVecWriter<'_> { "canonical graph replay exceeds its payload bound", )); } - if self.bytes.try_reserve_exact(bytes.len()).is_err() { - self.failure = Some(GraphDbError::budget_exhausted_count( - GraphBudgetKind::Write, - self.max_bytes, - )); - return Err(io::Error::other( - "canonical graph replay allocation exceeds its product budget", - )); + if next_len > self.bytes.capacity() { + let growth_target = if self.bytes.capacity() == 0 { + CHECKED_VEC_INITIAL_CAPACITY_BYTES + } else { + self.bytes + .capacity() + .checked_mul(2) + .unwrap_or(self.max_bytes) + }; + let target_capacity = growth_target.max(next_len).min(self.max_bytes); + let additional = target_capacity + .checked_sub(self.bytes.len()) + .ok_or_else(|| { + io::Error::other("canonical graph replay capacity is below its encoded length") + })?; + #[cfg(test)] + let capacity_before_reserve = self.bytes.capacity(); + if self.bytes.try_reserve_exact(additional).is_err() + || self.bytes.capacity() > self.max_bytes + { + self.failure = Some(GraphDbError::budget_exhausted_count( + GraphBudgetKind::Write, + self.max_bytes, + )); + return Err(io::Error::other( + "canonical graph replay allocation exceeds its product budget", + )); + } + #[cfg(test)] + if self.bytes.capacity() != capacity_before_reserve { + self.allocation_growths += 1; + } } let length = u64::try_from(bytes.len()) .map_err(|_| io::Error::other("canonical graph replay chunk is too large"))?; @@ -1022,3 +1056,65 @@ fn checked_canonical_bytes( .map_err(|error| GraphDbError::invalid(format!("failed to encode {subject}: {error}")))?; Ok(bytes) } + +#[cfg(test)] +mod checked_vec_writer_tests { + use sha2::{Digest, Sha256}; + use tracedecay_domain::canonical_text::encode_lowercase_hex; + + use super::{CheckedVecWriter, GraphDbError, checked_canonical_bytes}; + + #[test] + fn many_tiny_serde_writes_use_bounded_amortized_growth() { + let value = vec![0_u8; 4_096]; + let mut writer = + CheckedVecWriter::new(&|| Ok(()), 16 * 1_024).expect("bounded writer initializes"); + + serde_json::to_writer(&mut writer, &value).expect("fixture fits the writer bound"); + let allocation_growths = writer.allocation_growths(); + let actual = writer.finish().expect("bounded writer finishes"); + + assert_eq!( + actual, + serde_json::to_vec(&value).expect("fixture serializes") + ); + assert_eq!(actual.len(), 8_193); + assert_eq!( + encode_lowercase_hex(&Sha256::digest(&actual)), + "cb113f74dc19a08fcacd246b84ca69e1dff17209792ea3bd8d1b34397f5eca92" + ); + assert!( + allocation_growths <= 16, + "4,096 tiny values caused {allocation_growths} allocation growths" + ); + } + + #[test] + fn canonical_bytes_refuse_the_first_byte_past_the_bound() { + let value = vec![0_u8; 4_096]; + + let error = checked_canonical_bytes(&value, &|| Ok(()), "bounded fixture", 8_192) + .expect_err("8,193 encoded bytes must exceed the bound"); + + assert!(matches!( + error, + GraphDbError::InvalidRequest { message } + if message.contains("canonical graph replay exceeds its payload bound") + )); + } + + #[test] + fn writer_capacity_never_exceeds_its_payload_bound() { + let value = vec![0_u8; 4_096]; + let max_bytes = 8_192; + let mut writer = + CheckedVecWriter::new(&|| Ok(()), max_bytes).expect("bounded writer initializes"); + + let error = serde_json::to_writer(&mut writer, &value) + .expect_err("the final encoded byte must be refused"); + + assert!(error.to_string().contains("canonical graph replay exceeds")); + assert!(writer.bytes.len() <= max_bytes); + assert!(writer.bytes.capacity() <= max_bytes); + } +} From e78a80667d61402ef9d42679f823254f8d56a94d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 04:23:35 +0000 Subject: [PATCH 30/69] test(index): share lexical fixture capture bytes --- .../tracedecay-code-index/src/production/lexical_page_source.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay-code-index/src/production/lexical_page_source.rs b/crates/tracedecay-code-index/src/production/lexical_page_source.rs index 5c8dfa5a84..ee07f1e462 100644 --- a/crates/tracedecay-code-index/src/production/lexical_page_source.rs +++ b/crates/tracedecay-code-index/src/production/lexical_page_source.rs @@ -2753,7 +2753,7 @@ mod lexical_page_source_tests { snapshot, captured_files: vec![CodeIndexCapturedFileV1 { file_occurrence_id: file.file_occurrence_id, - sanitized_bytes: source.to_vec(), + sanitized_bytes: Arc::from(source), sensitivity_level: SensitivityLevelV1::Public, }], changed_files: BTreeSet::new(), From 86af4a4c31c865d1a48cd7d0031e876bd036aa45 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 04:37:30 +0000 Subject: [PATCH 31/69] perf(index): share captured snapshot bytes --- .../ignored_source_admissions.rs | 2 +- .../tests/code_index_suite/import_evidence.rs | 2 +- .../production_orchestration.rs | 12 +++---- .../parallel_equivalence.rs | 4 +-- .../benches/code_lexical_catchup.rs | 2 +- .../candidate_producers.rs | 2 +- .../src/candidate_output.rs | 6 ++-- .../src/candidate_output/cancellation.rs | 4 +-- src/daemon/code_index_scheduler.rs | 22 ++++--------- .../code_index_scheduler/git_tree_capture.rs | 33 ++++++++++++++++++- .../code_index_scheduler/memory_tests.rs | 6 ++-- 11 files changed, 59 insertions(+), 36 deletions(-) diff --git a/crates/tracedecay-code-index/tests/code_index_suite/ignored_source_admissions.rs b/crates/tracedecay-code-index/tests/code_index_suite/ignored_source_admissions.rs index 8ac3d120a3..e254ed49e6 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/ignored_source_admissions.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/ignored_source_admissions.rs @@ -50,7 +50,7 @@ fn add_present_typescript_file( }); request.captured_files.push(CodeIndexCapturedFileV1 { file_occurrence_id, - sanitized_bytes: bytes, + sanitized_bytes: Arc::from(bytes), sensitivity_level: SensitivityLevelV1::Public, }); request.changed_files.insert(logical_path.to_owned()); diff --git a/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs b/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs index 0d2873d0cd..78ff3227f8 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/import_evidence.rs @@ -57,7 +57,7 @@ fn import_request() -> CodeIndexBuildRequestV1 { }); request.captured_files.push(CodeIndexCapturedFileV1 { file_occurrence_id: second_occurrence, - sanitized_bytes: second_bytes, + sanitized_bytes: Arc::from(second_bytes), sensitivity_level: SensitivityLevelV1::Public, }); request.snapshot.content_identity = content_digest( diff --git a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs index a14b932e2c..7eae528956 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 @@ -346,7 +346,7 @@ fn request_at_path( snapshot, captured_files: vec![CodeIndexCapturedFileV1 { file_occurrence_id: file.file_occurrence_id, - sanitized_bytes: source.to_vec(), + sanitized_bytes: Arc::from(source), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }], changed_files: BTreeSet::new(), @@ -378,7 +378,7 @@ pub(super) fn request_with_source( let bytes = source.as_bytes().to_vec(); request.snapshot.files[0].content_digest = content_digest(&bytes); request.snapshot.content_identity = content_digest(&bytes); - request.captured_files[0].sanitized_bytes = bytes; + request.captured_files[0].sanitized_bytes = bytes.into(); request.repository_parse_identity.tree = Some(id::(tree)); request.changed_files.insert("src/lib.rs".to_owned()); request @@ -544,12 +544,12 @@ fn slow_parse_file_publishes_a_completed_generation_with_a_typed_omission() { captured_files: vec![ CodeIndexCapturedFileV1 { file_occurrence_id: fast.file_occurrence_id.clone(), - sanitized_bytes: fast_source.as_bytes().to_vec(), + sanitized_bytes: Arc::from(fast_source.as_bytes()), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }, CodeIndexCapturedFileV1 { file_occurrence_id: slow.file_occurrence_id.clone(), - sanitized_bytes: slow_source.into_bytes(), + sanitized_bytes: Arc::from(slow_source.into_bytes()), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }, ], @@ -1140,7 +1140,7 @@ fn verified_content_addressed_lexical_source_resumes_from_a_persisted_cursor() { content_digest(format!("{first_source}{second_source}").as_bytes()); request.captured_files.push(CodeIndexCapturedFileV1 { file_occurrence_id: second_file.file_occurrence_id.clone(), - sanitized_bytes: second_source.as_bytes().to_vec(), + sanitized_bytes: Arc::from(second_source.as_bytes()), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }); request.changed_files.clear(); @@ -1523,7 +1523,7 @@ fn verified_sealed_lexical_page_transition_is_canonical_across_importing_files() content_digest(format!("{first_source}{second_source}").as_bytes()); request.captured_files.push(CodeIndexCapturedFileV1 { file_occurrence_id: second_file.file_occurrence_id.clone(), - sanitized_bytes: second_source.as_bytes().to_vec(), + sanitized_bytes: Arc::from(second_source.as_bytes()), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }); request.changed_files.clear(); diff --git a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration/parallel_equivalence.rs b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration/parallel_equivalence.rs index 164c89b7e2..c1da24212f 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration/parallel_equivalence.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration/parallel_equivalence.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::{collections::BTreeSet, sync::Arc}; use tracedecay_code_index::{ chunks::content_digest, @@ -40,7 +40,7 @@ fn multi_file_request(file_count: usize, sealed_at: i64) -> CodeIndexBuildReques }); captured.push(CodeIndexCapturedFileV1 { file_occurrence_id: occurrence, - sanitized_bytes: bytes, + sanitized_bytes: Arc::from(bytes), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }); receipts.push(id::(&format!( diff --git a/crates/tracedecay-query/benches/code_lexical_catchup.rs b/crates/tracedecay-query/benches/code_lexical_catchup.rs index d1a9f71bb3..14bed00d0d 100644 --- a/crates/tracedecay-query/benches/code_lexical_catchup.rs +++ b/crates/tracedecay-query/benches/code_lexical_catchup.rs @@ -267,7 +267,7 @@ fn build_fixture() -> Fixture { .iter() .map(|(file, source)| CodeIndexCapturedFileV1 { file_occurrence_id: file.file_occurrence_id.clone(), - sanitized_bytes: source.clone(), + sanitized_bytes: Arc::from(source.clone()), sensitivity_level: SensitivityLevelV1::Public, }) .collect(), 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 f71c4f823d..d53b9f5e01 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -410,7 +410,7 @@ fn real_lexical_source_fixture_from_sources( .iter() .map(|(file, source)| CodeIndexCapturedFileV1 { file_occurrence_id: file.file_occurrence_id.clone(), - sanitized_bytes: source.clone(), + sanitized_bytes: Arc::from(source.clone()), sensitivity_level: SensitivityLevelV1::Public, }) .collect(), diff --git a/crates/tracedecay-search-eval/src/candidate_output.rs b/crates/tracedecay-search-eval/src/candidate_output.rs index 999d153a5c..ae3463b297 100644 --- a/crates/tracedecay-search-eval/src/candidate_output.rs +++ b/crates/tracedecay-search-eval/src/candidate_output.rs @@ -2705,7 +2705,7 @@ fn publish_corpus_with_scale( if indexable { captured.push(CodeIndexCapturedFileV1 { file_occurrence_id, - sanitized_bytes: bytes, + sanitized_bytes: Arc::from(bytes), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }); } @@ -2835,12 +2835,12 @@ fn publish_corpus_with_scale( "incremental fixture corpus document is not eligible".to_owned(), ) })?; - if changed.sanitized_bytes == after_bytes { + if changed.sanitized_bytes.as_ref() == after_bytes.as_slice() { return Err(CandidateOutputError::Contract( "incremental before/after fixture bytes are identical".to_owned(), )); } - changed.sanitized_bytes = after_bytes; + changed.sanitized_bytes = Arc::from(after_bytes); let changed_digest = content_digest(&changed.sanitized_bytes); let snapshot_file = incremental_snapshot .files diff --git a/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs b/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs index 5704e53463..d25dcd968c 100644 --- a/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs +++ b/crates/tracedecay-search-eval/src/candidate_output/cancellation.rs @@ -1,6 +1,6 @@ -use std::collections::BTreeSet; use std::fs; use std::path::Path; +use std::{collections::BTreeSet, sync::Arc}; use tracedecay_code_index::chunks::content_digest; use tracedecay_code_index::production::{ @@ -46,7 +46,7 @@ pub(super) fn prove_cancellation( }); captured.push(CodeIndexCapturedFileV1 { file_occurrence_id, - sanitized_bytes: bytes.clone(), + sanitized_bytes: Arc::from(bytes.clone()), sensitivity_level: tracedecay_domain::SensitivityLevelV1::Public, }); let snapshot = SanitizedCodeSnapshotV1 { diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 853c2e47a1..e618def640 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -1480,9 +1480,9 @@ struct CapturedCandidateV1 { captured: CodeIndexCapturedFileV1, receipt_id: SanitizationReceiptId, retained: Arc<[u8]>, - /// Charges both live source representations (interned `Arc` and production - /// input `Vec`) before the candidate can join a snapshot. The build copy's - /// half is released after production completes. + /// Charges the canonical source allocation before the candidate can join a + /// snapshot. Production borrows this allocation until its bounded intake + /// materialization, rather than retaining a second snapshot-wide copy. retained_reservation: Option, } @@ -1496,8 +1496,8 @@ struct CapturedSnapshotV1 { /// snapshot's bytes so identical content in sibling worktrees can reuse /// them (physical sharing without identity aliasing). retained_bytes: Vec>, - /// Pointer-paired resident charges for `retained_bytes` plus their live - /// production-input copies. Empty sources need no nonzero reservation. + /// Resident charges for `retained_bytes`. Empty sources need no nonzero + /// reservation. retained_reservations: Vec, } @@ -3618,10 +3618,7 @@ impl CodeIndexWorktreeSchedulerV1 { content_digest: &ContentDigest, retained_bytes: usize, ) -> Result, CodeIndexSchedulerErrorV1> { - let Some(requested_bytes) = u64::try_from(retained_bytes) - .ok() - .and_then(|bytes| bytes.checked_mul(2)) - .and_then(NonZeroU64::new) + let Some(requested_bytes) = u64::try_from(retained_bytes).ok().and_then(NonZeroU64::new) else { if retained_bytes == 0 { return Ok(None); @@ -3652,13 +3649,8 @@ impl CodeIndexWorktreeSchedulerV1 { } fn finish_snapshot_build_memory( - reservations: &mut [ResidentMemoryReservationV1], + _reservations: &mut [ResidentMemoryReservationV1], ) -> Result<(), CodeIndexSchedulerErrorV1> { - for reservation in reservations { - reservation - .shrink_to(reservation.reserved_bytes() / 2) - .map_err(CodeIndexSchedulerErrorV1::SnapshotMemoryAdjustment)?; - } Ok(()) } diff --git a/src/daemon/code_index_scheduler/git_tree_capture.rs b/src/daemon/code_index_scheduler/git_tree_capture.rs index 074a86c9eb..8918ae6545 100644 --- a/src/daemon/code_index_scheduler/git_tree_capture.rs +++ b/src/daemon/code_index_scheduler/git_tree_capture.rs @@ -199,7 +199,7 @@ impl CodeIndexWorktreeSchedulerV1 { }, captured: CodeIndexCapturedFileV1 { file_occurrence_id: occurrence, - sanitized_bytes: shared.to_vec(), + sanitized_bytes: Arc::clone(&shared), sensitivity_level, }, receipt_id, @@ -674,6 +674,37 @@ mod tests { ); } + #[test] + fn exact_git_capture_reuses_one_byte_owner_for_snapshot_and_production() { + let (project, _store, scheduler) = generated_source_fixture(); + let revision = git_output(project.path(), &["rev-parse", "HEAD"]); + let tree = git_output(project.path(), &["rev-parse", "HEAD^{tree}"]); + + let captured = scheduler + .capture_exact_git_tree_snapshot( + &ExactGitTreeSourceV1 { + reference: tracedecay_domain::RefId::new("refs/heads/main") + .expect("reference"), + revision: tracedecay_domain::CommitId::new(revision).expect("revision"), + tree: tracedecay_domain::TreeId::new(tree).expect("tree"), + }, + &branch_generations::BranchGenerationReadControlV1 { + deadline: None, + cancellation: None, + }, + ) + .expect("capture exact Git tree"); + + assert_eq!(captured.captured_files.len(), captured.retained_bytes.len()); + assert!( + captured.captured_files.iter().all(|captured_file| captured + .retained_bytes + .iter() + .any(|retained| Arc::ptr_eq(&captured_file.sanitized_bytes, retained))), + "production input must retain the snapshot's canonical byte allocation" + ); + } + /// The reference is provenance, and provenance a repository cannot vouch /// for is refused: a capture may resolve any commit in the object database, /// but never stamp it with a branch name that does not exist. diff --git a/src/daemon/code_index_scheduler/memory_tests.rs b/src/daemon/code_index_scheduler/memory_tests.rs index 9771577652..1dc8b1ef31 100644 --- a/src/daemon/code_index_scheduler/memory_tests.rs +++ b/src/daemon/code_index_scheduler/memory_tests.rs @@ -83,13 +83,13 @@ fn captured_source_bytes_are_charged_until_the_snapshot_drops() { .map(|bytes| bytes.len() as u64) .sum::(); assert!(retained_bytes > 0); - assert_eq!(authority.snapshot().used_bytes, retained_bytes * 2); + assert_eq!(authority.snapshot().used_bytes, retained_bytes); drop(captured); assert_eq!(authority.snapshot().used_bytes, 0); } #[test] -fn completed_reconcile_releases_the_captured_file_copy_charge() { +fn completed_reconcile_retains_the_canonical_snapshot_charge() { let project = fixture(); let store = TempDir::new().expect("store root"); let mut scheduler = CodeIndexWorktreeSchedulerV1::open( @@ -122,7 +122,7 @@ fn completed_reconcile_releases_the_captured_file_copy_charge() { assert_eq!( authority.snapshot().used_bytes, retained_bytes, - "the no-build path must drop captured Vec copies before retaining only the Arc charge" + "the no-build path retains only the canonical Arc source charge" ); } From 7b684272ab9928459d3eb7fc2cbf4fb724ea8043 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 04:38:32 +0000 Subject: [PATCH 32/69] perf(index): serve retained text before graph activation --- .../src/production/lexical_page_source.rs | 7 + .../src/production/mod.rs | 2 +- src/daemon/code_index_scheduler.rs | 366 +++++++++++++++--- .../code_index_scheduler/query_runtime.rs | 133 +++++-- src/daemon/code_index_scheduler/registry.rs | 153 +++++++- .../registry/scope_identity.rs | 13 +- src/daemon/code_index_scheduler/tests.rs | 132 ++++++- .../query_authority_upgrade.rs | 19 +- 8 files changed, 690 insertions(+), 135 deletions(-) diff --git a/crates/tracedecay-code-index/src/production/lexical_page_source.rs b/crates/tracedecay-code-index/src/production/lexical_page_source.rs index ee07f1e462..193c80addc 100644 --- a/crates/tracedecay-code-index/src/production/lexical_page_source.rs +++ b/crates/tracedecay-code-index/src/production/lexical_page_source.rs @@ -779,6 +779,13 @@ pub struct VerifiedSealedTextGenerationMetadataV1 { } impl VerifiedSealedTextGenerationMetadataV1 { + pub fn from_published_generation(generation: &CodeIndexPublishedGenerationV1) -> Self { + Self { + manifest: generation.manifest().clone(), + snapshot: generation.snapshot().clone(), + } + } + pub fn manifest(&self) -> &CodeGenerationManifestV1 { &self.manifest } diff --git a/crates/tracedecay-code-index/src/production/mod.rs b/crates/tracedecay-code-index/src/production/mod.rs index b4baddc098..cf645b623b 100644 --- a/crates/tracedecay-code-index/src/production/mod.rs +++ b/crates/tracedecay-code-index/src/production/mod.rs @@ -79,7 +79,7 @@ pub use lexical_page_source::{ VerifiedSealedLexicalCursorV1, VerifiedSealedLexicalPageBatchBoundsV1, VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, - VerifiedSealedLexicalSourceReceiptV1, + VerifiedSealedLexicalSourceReceiptV1, VerifiedSealedTextGenerationMetadataV1, }; mod sealed_codec; pub use sealed_codec::{ diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index e618def640..0025122d3f 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -65,7 +65,7 @@ use crate::{ CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, SharedPhysicalCodeArtifactPoolV1, VerifiedSealedLexicalPageBatchBoundsV1, VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageSourceV1, - VerifiedSealedLexicalSourceReceiptV1, + VerifiedSealedLexicalSourceReceiptV1, VerifiedSealedTextGenerationMetadataV1, }, projection::{ ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, @@ -1757,8 +1757,15 @@ impl CodeIndexBuildProgressStateV1 { #[derive(Clone)] pub(in crate::daemon) struct LatestCompleteCodeIndexV1 { generation: Arc, - query_owners: Arc>>, + text: LatestCodeTextGenerationV1, record_index: Arc>, + graph_activation: Arc>, +} + +#[derive(Clone)] +pub(in crate::daemon) struct LatestCodeTextGenerationV1 { + metadata: Arc, + query_owners: Arc>>, /// Generation-owned partial durable text-artifact build. Only the /// background scheduler advances it; /// foreground queries observe typed warming until the immutable owners @@ -1777,7 +1784,19 @@ pub(in crate::daemon) struct LatestCompleteCodeIndexV1 { text_progress_producer_incarnation: u64, /// The durable text-artifact store for this generation's store root. text_artifact_store: DaemonCodeTextArtifactStoreV1, - graph_activation: Arc>, + /// A cold graph-off bind authenticates the sealed source once and hands + /// that same reader to the artifact build. The full generation is never + /// decoded merely to discover text metadata or source layout. + preopened_source: Arc>>>, + publication_binding: Option>, +} + +impl std::ops::Deref for LatestCompleteCodeIndexV1 { + type Target = LatestCodeTextGenerationV1; + + fn deref(&self) -> &Self::Target { + &self.text + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -2154,13 +2173,44 @@ impl DaemonCodeTextArtifactStoreV1 { .map_err(map_sealed_page_source_error) } + fn open_sealed_source_with_progress( + &self, + identity: &DurableSealedCodeGenerationIdentityV1, + control: &dyn CodeIndexExecutionControlV1, + progress: F, + ) -> Result, RetrievalPortError> + where + F: FnMut(u64, u64), + { + DaemonCodeIndexPublicationStoreV1::validate_generation_file(&identity.locator) + .map_err(|error| RetrievalPortError::Contract(error.to_string()))?; + let path = self.publication.generations_root.join(&identity.locator); + let metadata = path.symlink_metadata().map_err(text_artifact_unavailable)?; + if !metadata.file_type().is_file() || metadata.len() != identity.size_bytes { + return Err(RetrievalPortError::Contract( + "durable sealed lexical source identity is corrupt".to_owned(), + )); + } + let file = File::open(path).map_err(text_artifact_unavailable)?; + VerifiedSealedLexicalPageSourceV1::open_content_addressed_with_progress( + file, + identity.size_bytes, + identity.digest.clone(), + TEXT_ARTIFACT_PAGE_CHUNKS_V1, + TEXT_ARTIFACT_PAGE_BYTES_V1, + control, + progress, + ) + .map_err(map_sealed_page_source_error) + } + /// Durably publish one finalized staging artifact: content-address it, /// move it into the artifacts root, fsync the directory, and attach the /// descriptor to the sealed generation entry under the store lock. fn publish( &self, staging_path: &Path, - generation: &CodeIndexPublishedGenerationV1, + generation_id: &CodeGenerationId, sealed_identity: &DurableSealedCodeGenerationIdentityV1, control: &dyn CodeIndexExecutionControlV1, ) -> Result { @@ -2176,7 +2226,7 @@ impl DaemonCodeTextArtifactStoreV1 { let (artifact_hex, artifact_size_bytes) = sha256_private_file_hex_and_size(staging_path, control)?; let descriptor = DurableCodeTextArtifactDescriptorV1 { - generation_id: generation.manifest().generation_id.clone(), + generation_id: generation_id.clone(), artifact_file: format!("text-artifact-{artifact_hex}.bin"), artifact_digest: ManifestDigest::from_sha256_bytes( &hex::decode(&artifact_hex).map_err(text_artifact_unavailable)?, @@ -2261,6 +2311,10 @@ enum CodeGraphServingAuthorityV1 { } impl LatestCompleteCodeIndexV1 { + pub(in crate::daemon) fn text_generation_handle(&self) -> LatestCodeTextGenerationV1 { + self.text.clone() + } + pub(in crate::daemon) fn generation(&self) -> &CodeIndexPublishedGenerationV1 { self.generation.as_ref() } @@ -2316,6 +2370,12 @@ impl LatestCompleteCodeIndexV1 { }; let _ = self.install_graph_serving(reader, None, CodeGraphServingAuthorityV1::Memory); } +} + +impl LatestCodeTextGenerationV1 { + pub(in crate::daemon) fn metadata(&self) -> &VerifiedSealedTextGenerationMetadataV1 { + &self.metadata + } #[cfg(test)] fn activate_text_serving(&self) -> Result<(), RetrievalPortError> { @@ -2324,17 +2384,19 @@ impl LatestCompleteCodeIndexV1 { "code-index text serving owners are warming".to_owned(), )); } - let _ = self.record_index(); - let _ = self.generation.test_attribution_authority(); Ok(()) } +} +impl LatestCompleteCodeIndexV1 { /// Whether the record lookup indices are already built for this generation. #[cfg(test)] fn record_index_is_warm(&self) -> bool { self.record_index.get().is_some() } +} +impl LatestCodeTextGenerationV1 { /// Whether the exact/lexical lane owners are already built. #[cfg(test)] fn query_owners_are_warm(&self) -> bool { @@ -2352,7 +2414,9 @@ impl LatestCompleteCodeIndexV1 { fn mark_text_serving_failed(&self) { self.text_projection_failed.store(true, Ordering::Release); } +} +impl LatestCompleteCodeIndexV1 { fn semantic_evaluation_snapshot(&self) -> SemanticEvaluationCodeSnapshotV1 { SemanticEvaluationCodeSnapshotV1 { source_generation: self.generation.manifest().generation_id.clone(), @@ -2401,7 +2465,9 @@ impl LatestCompleteCodeIndexV1 { pub fn graph_abstentions(&self) -> &[crate::code_index::chunks::CodeIndexEdgeAbstentionV1] { self.generation.edge_abstentions() } +} +impl LatestCodeTextGenerationV1 { /// Return exact and lexical query owners bound to the latest complete /// published generation. #[cfg(test)] @@ -2427,7 +2493,9 @@ impl LatestCompleteCodeIndexV1 { ) }) } +} +impl LatestCompleteCodeIndexV1 { fn production_graph_serving( &self, ) -> Result, RetrievalPortError> { @@ -2482,10 +2550,12 @@ impl LatestCompleteCodeIndexV1 { Err(error) => Err(RetrievalPortError::Contract(error.to_string())), } } +} +impl LatestCodeTextGenerationV1 { fn source_freshness(&self) -> Result { production_code_index_freshness( - self.generation.manifest().seal.sealed_at, + self.metadata.manifest().seal.sealed_at, ComponentRevision::new("policy.daemon.v1") .map_err(|error| RetrievalPortError::Contract(error.to_string()))?, ) @@ -2494,13 +2564,13 @@ impl LatestCompleteCodeIndexV1 { fn text_projection_metadata( &self, ) -> Result { - let generation_id = self.generation.manifest().generation_id.clone(); + let generation_id = self.metadata.manifest().generation_id.clone(); let freshness = self.source_freshness()?; Ok(CodeLexicalProjectionMetadataV1 { generation: generation_id, - repository_id: Some(self.generation.snapshot().repository.clone()), + repository_id: Some(self.metadata.snapshot().repository.clone()), logical_paths: self - .generation + .metadata .snapshot() .files .iter() @@ -2570,7 +2640,7 @@ impl LatestCompleteCodeIndexV1 { .unwrap_or_else(std::sync::PoisonError::into_inner) .snapshot() .filter(|snapshot| { - snapshot.generation_id == self.generation.manifest().generation_id.as_str() + snapshot.generation_id == self.metadata.manifest().generation_id.as_str() }) .and_then(|snapshot| snapshot.last_commit_latency_micros) }); @@ -2595,7 +2665,7 @@ impl LatestCompleteCodeIndexV1 { let (files_per_second, lexical_bytes_per_second, estimated_remaining_seconds) = state.rates_and_eta(total_lexical_bytes); let snapshot = CodeIndexBuildProgressV1 { - generation_id: self.generation.manifest().generation_id.as_str().to_owned(), + generation_id: self.metadata.manifest().generation_id.as_str().to_owned(), daemon_incarnation: self.text_progress_daemon_incarnation, producer_incarnation: self.text_progress_producer_incarnation, progress_epoch: 0, @@ -2631,7 +2701,7 @@ impl LatestCompleteCodeIndexV1 { source: &VerifiedSealedLexicalPageSourceV1, ) -> Result { let artifact = reader.verified_artifact(); - let generation_id = &self.generation.manifest().generation_id; + let generation_id = &self.metadata.manifest().generation_id; if artifact.generation() != generation_id { return Err(RetrievalPortError::GenerationMismatch); } @@ -2668,7 +2738,7 @@ impl LatestCompleteCodeIndexV1 { } fn publish_text_progress_snapshot(&self, snapshot: CodeIndexBuildProgressV1) { - let generation_id = &self.generation.manifest().generation_id; + let generation_id = &self.metadata.manifest().generation_id; hotpath::measure_block!("query.artifact.progress.publish", { let _ = self .text_progress_slot @@ -2684,7 +2754,7 @@ impl LatestCompleteCodeIndexV1 { current_batch_pages: u64, current_batch_payload_bytes: u64, ) { - let generation_id = &self.generation.manifest().generation_id; + let generation_id = &self.metadata.manifest().generation_id; let elapsed_micros = self .text_progress_state .lock() @@ -2711,7 +2781,7 @@ impl LatestCompleteCodeIndexV1 { } fn publish_text_progress_blocked(&self, reason: CodeIndexBuildBlockedReasonV1) { - let generation_id = &self.generation.manifest().generation_id; + let generation_id = &self.metadata.manifest().generation_id; hotpath::measure_block!("query.artifact.progress.publish", { let mut slot = self .text_progress_slot @@ -2772,6 +2842,17 @@ impl LatestCompleteCodeIndexV1 { } fn advance_text_serving_inner(&self, maximum_work: usize) -> Result { + if let Some(binding) = self.publication_binding.as_ref() { + let current = self + .text_artifact_store + .publication + .read_publication_pointer() + .map_err(text_artifact_unavailable)?; + if current.as_ref() != Some(binding.as_ref()) { + self.text_control.retire(); + return Err(RetrievalPortError::Cancelled); + } + } if self.query_owners.get().is_some() { return Ok(true); } @@ -2790,6 +2871,23 @@ impl LatestCompleteCodeIndexV1 { self.text_control.clone() } + fn take_preopened_source_or_open( + &self, + sealed_identity: &DurableSealedCodeGenerationIdentityV1, + control: &dyn CodeIndexExecutionControlV1, + ) -> Result, RetrievalPortError> { + if let Some(source) = self + .preopened_source + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + return Ok(source); + } + self.text_artifact_store + .open_sealed_source(sealed_identity, control) + } + /// The durable-artifact journey: reopen a published head when one exists, /// otherwise stream the sealed generation through the staging builder one /// bounded page window at a time, finalize, publish, and reopen. @@ -2802,7 +2900,7 @@ impl LatestCompleteCodeIndexV1 { ) -> Result { let store = &self.text_artifact_store; if build.is_none() { - let generation_id = self.generation.manifest().generation_id.clone(); + let generation_id = self.metadata.manifest().generation_id.clone(); if let Some(descriptor) = store.published_descriptor(&generation_id)? { // Durable-head reopen: a restart serves the published // artifact without rebuilding it. Reserve the complete reader @@ -2824,7 +2922,8 @@ impl LatestCompleteCodeIndexV1 { match reader { Ok(reader) => { let sealed_identity = store.sealed_identity(&generation_id)?; - let source = store.open_sealed_source(&sealed_identity, control)?; + let source = + self.take_preopened_source_or_open(&sealed_identity, control)?; let ready_progress = self.ready_text_progress_snapshot(&reader, &sealed_identity, &source)?; drop(source); @@ -2867,7 +2966,7 @@ impl LatestCompleteCodeIndexV1 { let artifacts_root = code_text_artifacts_root(store.store_root()); ensure_private_text_artifacts_root(&artifacts_root)?; let staging_path = artifacts_root.join(format!(".text-artifact-{sealed_hex}.staging")); - let mut source = store.open_sealed_source(&sealed_identity, control)?; + let mut source = self.take_preopened_source_or_open(&sealed_identity, control)?; let builder_budget = text_artifact_builder_budget(source.staging_window_bytes())?; let metadata = self.text_projection_metadata()?; let builder = if staging_path.exists() { @@ -3182,12 +3281,12 @@ impl LatestCompleteCodeIndexV1 { drop(source); let descriptor = store.publish( &staging_path, - self.generation.as_ref(), + &self.metadata.manifest().generation_id, &sealed_identity, control, )?; let reader_reservation = store.reserve_resident_memory( - &self.generation.manifest().generation_id, + &self.metadata.manifest().generation_id, "code-text-artifact-reader", CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, )?; @@ -3226,11 +3325,11 @@ impl LatestCompleteCodeIndexV1 { reader_reservation, )); let _ = self.query_owners.set(owners); - let _ = self.record_index(); - let _ = self.generation.test_attribution_authority(); Ok(()) } +} +impl LatestCompleteCodeIndexV1 { fn install_graph_serving( &self, graph_reader: CodeGraphEvidenceReader, @@ -3871,6 +3970,134 @@ impl CodeIndexWorktreeSchedulerV1 { Some(self.bind_latest_complete(generation)) } + /// Bind exact/lexical serving directly from the canonical active pointer. + /// + /// This authenticates the complete sealed content address and only decodes + /// its bounded manifest/snapshot header. Graph, record-index, attribution, + /// and semantic owners retain the full-generation decode path. + pub(super) fn servable_retained_text_generation( + &mut self, + ) -> Option { + if self.shutting_down.load(Ordering::Acquire) { + return None; + } + let resolved = identity::IndexingIdentityV1::resolve(&self.project_root).ok()?; + if !resolved.authorizes_reuse_of(&self.identity) { + return None; + } + let pointer = self.publication.read_publication_pointer().ok().flatten()?; + let generation_id = CodeGenerationId::new(pointer.generation_id.clone()).ok()?; + let entry = pointer + .generation_index + .iter() + .find(|entry| entry.generation_id == pointer.generation_id)?; + let sealed_identity = DurableSealedCodeGenerationIdentityV1 { + locator: entry.generation_file.clone(), + digest: ManifestDigest::new(entry.state_digest.clone()).ok()?, + size_bytes: entry.size_bytes, + }; + let text_progress_owner_epoch = hotpath::measure_block!( + "query.artifact.progress.publish", + self.build_progress + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .replace_generation(generation_id.clone()) + ); + let text_progress_state = Arc::new(Mutex::new(CodeIndexBuildProgressStateV1::new())); + let text_control = GenerationTextControlV1::new(Arc::clone(&self.shutting_down)); + let text_artifact_store = DaemonCodeTextArtifactStoreV1::bind( + &self.store_root, + &self.publication, + &self.resident_memory, + &self.project_id, + &self.worktree_id, + ); + let progress_slot = Arc::clone(&self.build_progress); + let progress_generation = generation_id.clone(); + let progress_digest = sealed_identity.digest.as_str().to_owned(); + let progress_state = Arc::clone(&text_progress_state); + let progress_daemon_incarnation = self.progress_daemon_incarnation; + let progress_producer_incarnation = self.progress_producer_incarnation; + let source = text_artifact_store + .open_sealed_source_with_progress( + &sealed_identity, + &text_control, + move |scanned, total| { + let elapsed_micros = progress_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .elapsed_micros(); + let snapshot = CodeIndexBuildProgressV1 { + generation_id: progress_generation.as_str().to_owned(), + daemon_incarnation: progress_daemon_incarnation, + producer_incarnation: progress_producer_incarnation, + progress_epoch: 0, + sealed_source_digest: progress_digest.clone(), + phase: CodeIndexBuildPhaseV1::SourceScan, + committed_pages: 0, + committed_chunks: 0, + committed_imports: 0, + committed_payload_bytes: 0, + completed_files: 0, + total_files: 0, + completed_lexical_bytes: scanned, + total_lexical_bytes: total, + current_batch_pages: 0, + current_batch_payload_bytes: 0, + elapsed_micros, + last_commit_latency_micros: None, + files_per_second: None, + lexical_bytes_per_second: None, + estimated_remaining_seconds: None, + last_progress_micros: now_micros().0, + blocked_reason: None, + }; + let _ = progress_slot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .publish(&progress_generation, text_progress_owner_epoch, snapshot); + }, + ) + .ok()?; + let metadata = source.metadata(); + if metadata.manifest().project_id != self.project_id + || metadata.manifest().generation_id != generation_id + || metadata.snapshot().repository != self.repository_id + || metadata.snapshot().worktree.as_ref() != Some(&self.worktree_id) + || metadata.snapshot().content_identity.as_str() != pointer.snapshot_content_identity + { + text_control.retire(); + return None; + } + if self + .publication + .read_publication_pointer() + .ok() + .flatten() + .as_ref() + != Some(&pointer) + { + text_control.retire(); + return None; + } + let metadata = Arc::new(source.metadata().clone()); + Some(LatestCodeTextGenerationV1 { + metadata, + query_owners: Arc::new(OnceLock::new()), + text_projection_build: Arc::new(Mutex::new(None)), + text_projection_failed: Arc::new(AtomicBool::new(false)), + text_control, + text_progress_state, + text_progress_slot: Arc::clone(&self.build_progress), + text_progress_owner_epoch, + text_progress_daemon_incarnation: self.progress_daemon_incarnation, + text_progress_producer_incarnation: self.progress_producer_incarnation, + text_artifact_store, + preopened_source: Arc::new(Mutex::new(Some(source))), + publication_binding: Some(Arc::new(pointer)), + }) + } + /// Retained-owner activation entry point. Foreground reads never call this. pub(super) fn activate_or_reconcile( &mut self, @@ -4429,25 +4656,33 @@ impl CodeIndexWorktreeSchedulerV1 { ) } }; + let metadata = Arc::new( + VerifiedSealedTextGenerationMetadataV1::from_published_generation(&generation), + ); LatestCompleteCodeIndexV1 { generation, - query_owners, + text: LatestCodeTextGenerationV1 { + metadata, + query_owners, + text_projection_build, + text_projection_failed, + text_control, + text_progress_state, + text_progress_slot: Arc::clone(&self.build_progress), + text_progress_owner_epoch, + text_progress_daemon_incarnation: self.progress_daemon_incarnation, + text_progress_producer_incarnation: self.progress_producer_incarnation, + text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( + &self.store_root, + &self.publication, + &self.resident_memory, + &self.project_id, + &self.worktree_id, + ), + preopened_source: Arc::new(Mutex::new(None)), + publication_binding: None, + }, record_index, - text_projection_build, - text_projection_failed, - text_control, - text_progress_state, - text_progress_slot: Arc::clone(&self.build_progress), - text_progress_owner_epoch, - text_progress_daemon_incarnation: self.progress_daemon_incarnation, - text_progress_producer_incarnation: self.progress_producer_incarnation, - text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( - &self.store_root, - &self.publication, - &self.resident_memory, - &self.project_id, - &self.worktree_id, - ), graph_activation, } } @@ -4499,29 +4734,40 @@ impl CodeIndexWorktreeSchedulerV1 { let mut progress_slot = CodeIndexBuildProgressSlotStateV1::default(); let text_progress_owner_epoch = progress_slot.replace_generation(generation_id); + let metadata = Arc::new( + VerifiedSealedTextGenerationMetadataV1::from_published_generation( + &generation, + ), + ); LatestCompleteCodeIndexV1 { generation, - query_owners: Arc::new(OnceLock::new()), + text: LatestCodeTextGenerationV1 { + metadata, + query_owners: Arc::new(OnceLock::new()), + text_projection_build: Arc::new(Mutex::new(None)), + text_projection_failed: Arc::new(AtomicBool::new(false)), + text_control: GenerationTextControlV1::new(Arc::clone( + &self.shutting_down, + )), + text_progress_state: Arc::new(Mutex::new( + CodeIndexBuildProgressStateV1::new(), + )), + text_progress_slot: Arc::new(RwLock::new(progress_slot)), + text_progress_owner_epoch, + text_progress_daemon_incarnation: self.progress_daemon_incarnation, + text_progress_producer_incarnation: self + .progress_producer_incarnation, + text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( + &self.store_root, + &self.publication, + &self.resident_memory, + &self.project_id, + &self.worktree_id, + ), + preopened_source: Arc::new(Mutex::new(None)), + publication_binding: None, + }, record_index: Arc::new(OnceLock::new()), - text_projection_build: Arc::new(Mutex::new(None)), - text_projection_failed: Arc::new(AtomicBool::new(false)), - text_control: GenerationTextControlV1::new(Arc::clone( - &self.shutting_down, - )), - text_progress_state: Arc::new(Mutex::new( - CodeIndexBuildProgressStateV1::new(), - )), - text_progress_slot: Arc::new(RwLock::new(progress_slot)), - text_progress_owner_epoch, - text_progress_daemon_incarnation: self.progress_daemon_incarnation, - text_progress_producer_incarnation: self.progress_producer_incarnation, - text_artifact_store: DaemonCodeTextArtifactStoreV1::bind( - &self.store_root, - &self.publication, - &self.resident_memory, - &self.project_id, - &self.worktree_id, - ), graph_activation: Arc::new(RwLock::new( CodeGraphActivationStateV1::Pending, )), diff --git a/src/daemon/code_index_scheduler/query_runtime.rs b/src/daemon/code_index_scheduler/query_runtime.rs index 3d57906c5d..f9b767297f 100644 --- a/src/daemon/code_index_scheduler/query_runtime.rs +++ b/src/daemon/code_index_scheduler/query_runtime.rs @@ -118,11 +118,18 @@ pub(in crate::daemon) async fn mount_core_query_authority_on_project_open( scope: &ResolvedScope, cursor_keys: &crate::global_db::session_temporal::GlobalDbCursorKeyProvider, ) -> Result<(), QueryRuntimeMountErrorV1> { - let latest = registry - .latest_complete_fresh_for_scope(scope) - .await - .ok_or(QueryRuntimeMountErrorV1::GenerationUnavailable)?; - let privacy_domain = latest.generation.manifest().privacy_domain.clone(); + let privacy_domain = if let Some(text) = registry.latest_text_serving_for_scope(scope).await { + text.metadata().manifest().privacy_domain.clone() + } else { + registry + .latest_complete_fresh_for_scope(scope) + .await + .ok_or(QueryRuntimeMountErrorV1::GenerationUnavailable)? + .generation + .manifest() + .privacy_domain + .clone() + }; let workload: crate::search_eval::CandidateWorkloadV1 = serde_json::from_str(QUERY_FALLBACK_WORKLOAD_JSON) .map_err(|error| QueryRuntimeMountErrorV1::InvalidFallbackPolicy(error.to_string()))?; @@ -231,11 +238,18 @@ pub(in crate::daemon) async fn mount_query_authority_on_project_open( scope: &ResolvedScope, provider: &dyn QueryAuthorityProviderV1, ) -> Result<(), QueryRuntimeMountErrorV1> { - let latest = registry - .latest_complete_fresh_for_scope(scope) - .await - .ok_or(QueryRuntimeMountErrorV1::GenerationUnavailable)?; - let privacy_domain = latest.generation.manifest().privacy_domain.clone(); + let privacy_domain = if let Some(text) = registry.latest_text_serving_for_scope(scope).await { + text.metadata().manifest().privacy_domain.clone() + } else { + registry + .latest_complete_fresh_for_scope(scope) + .await + .ok_or(QueryRuntimeMountErrorV1::GenerationUnavailable)? + .generation + .manifest() + .privacy_domain + .clone() + }; let authority = prepare_query_authority(scope, &privacy_domain, provider)?; registry .mount_query_authority(project_root, scope, authority) @@ -393,26 +407,40 @@ impl CodeIndexSchedulerRegistryV1 { Some(ready) => (ready, false), None => (serving, true), }, - None => match self.latest_complete_ready_for_scope(scope).await { - Some(ready) => (ready, false), - None => { - // Nothing servable and the ready gate refused. Search is the - // one lane whose resolution never runs the freshness ladder, - // so nothing else on this path will ever request the rebuild - // that would remedy the failure — it would return this typed - // error forever. Ask for the remedy exactly once per - // admission (debounced on the pending wake), never inline and - // never parking, then still fail typed rather than degrade - // into an empty answer. - self.request_query_background_reconcile(scope).await; - let unverified = self.generation_is_unverified_for_scope(scope).await; - return Err(if unverified { - QuerySearchExecutionErrorV1::GenerationUnverified - } else { - QuerySearchExecutionErrorV1::GenerationUnavailable - }); + None => { + if let Some(text) = self.latest_text_serving_for_scope(scope).await { + return execute_query_search_on_text( + self, + scope, + input, + text, + None, + true, + graph_control, + ) + .await; } - }, + match self.latest_complete_ready_for_scope(scope).await { + Some(ready) => (ready, false), + None => { + // Nothing servable and the ready gate refused. Search is the + // one lane whose resolution never runs the freshness ladder, + // so nothing else on this path will ever request the rebuild + // that would remedy the failure — it would return this typed + // error forever. Ask for the remedy exactly once per + // admission (debounced on the pending wake), never inline and + // never parking, then still fail typed rather than degrade + // into an empty answer. + self.request_query_background_reconcile(scope).await; + let unverified = self.generation_is_unverified_for_scope(scope).await; + return Err(if unverified { + QuerySearchExecutionErrorV1::GenerationUnverified + } else { + QuerySearchExecutionErrorV1::GenerationUnavailable + }); + } + } + } }; execute_query_search_on_latest(self, scope, input, latest, served_stale, graph_control) .await @@ -452,6 +480,31 @@ async fn execute_query_search_on_latest( served_stale: bool, graph_control: Arc, ) -> Result +where + C: GraphExecutionControl + 'static, +{ + let text = latest.text_generation_handle(); + execute_query_search_on_text( + schedulers, + scope, + input, + text, + Some(latest), + served_stale, + graph_control, + ) + .await +} + +async fn execute_query_search_on_text( + schedulers: &CodeIndexSchedulerRegistryV1, + scope: &ResolvedScope, + input: QuerySearchExecutionRequestV1, + text: super::LatestCodeTextGenerationV1, + graph_latest: Option, + served_stale: bool, + graph_control: Arc, +) -> Result where C: GraphExecutionControl + 'static, { @@ -459,26 +512,27 @@ where .query_authority_for_scope(scope) .await .ok_or(QuerySearchExecutionErrorV1::AuthorityUnavailable)?; - let generation = latest.generation.manifest().generation_id.clone(); + let metadata = text.metadata(); + let generation = metadata.manifest().generation_id.clone(); let request = RetrievalRequest { principal: input.principal, scope: RetrievalScope { - privacy_domain: latest.generation.manifest().privacy_domain.clone(), + privacy_domain: metadata.manifest().privacy_domain.clone(), root: SingleRootScopeV1 { - repository: latest.generation.snapshot().repository.clone(), - worktree: latest.generation.snapshot().worktree.clone(), - reference: latest.generation.snapshot().reference.clone(), + repository: metadata.snapshot().repository.clone(), + worktree: metadata.snapshot().worktree.clone(), + reference: metadata.snapshot().reference.clone(), }, }, temporal_mode: TemporalModeV1::Current, snapshot: RetrievalSnapshot { watermarks: VectorWatermark::default(), freshness_digest: FreshnessVectorDigest::new( - latest.generation.manifest().snapshot_digest.as_str(), + metadata.manifest().snapshot_digest.as_str(), ) .map_err(|error| QuerySearchExecutionErrorV1::InvalidPolicy(error.to_string()))?, authorization_revision: input.authorization_revision, - captured_at: latest.generation.manifest().seal.sealed_at, + captured_at: metadata.manifest().seal.sealed_at, }, profile_id: authority.profile().profile_id.clone(), budget: authority.profile().retrieval_budget, @@ -487,7 +541,7 @@ where .sanitize(input.sanitizer_revision, input.normalization_revision)?; let request = sanitized.request(); let query_view = sanitized.query_view(); - let owners = latest.production_query_owners_with_budget(&request.budget)?; + let owners = text.production_query_owners_with_budget(&request.budget)?; let parser = CentralExactAdmissionAuthorityV1::new(input.exact_rule_revision); let exact = owners.retrieve_exact(&ExactLaneRequest { base: request.clone(), @@ -515,7 +569,10 @@ where RetrieverOutcome::Unavailable(RetrievalFailure::AuthorityUnavailable { detail: "exact and lexical lanes produced no graph seed".to_owned(), }) - } else if let Ok(graph_serving) = latest.production_graph_serving() { + } else if let Some(graph_serving) = graph_latest + .as_ref() + .and_then(|latest| latest.production_graph_serving().ok()) + { graph_serving.graph.retrieve_graph( &GraphLaneRequest { base: request.clone(), diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 45eadfdd39..6753cbdf61 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -31,8 +31,8 @@ use super::{ CodeIndexCadenceTriggerV1, CodeIndexEventToReadyReceiptV1, CodeIndexNoopEvidenceV1, CodeIndexPublishEvidenceV1, CodeIndexReconcileOutcomeV1, CodeIndexSchedulerErrorV1, CodeIndexWorktreeSchedulerV1, DaemonCodeIndexControlV1, GenerationDecodeAdmissionV1, - LatestCompleteCodeIndexV1, PendingHintsV1, SharedCodeIndexBytePoolV1, - newly_eligible_percentile, now_micros, + LatestCodeTextGenerationV1, LatestCompleteCodeIndexV1, PendingHintsV1, + SharedCodeIndexBytePoolV1, newly_eligible_percentile, now_micros, }; #[cfg(test)] use super::{CodeIndexBytePoolStatsV1, CodeIndexCadenceReadModelV1}; @@ -46,7 +46,7 @@ mod runtime_generation_census_tests; mod scope_identity; use self::ignored_dependencies::exact_activated_serving_generation; -pub(super) use scope_identity::latest_matches_scope_identity; +pub(super) use scope_identity::{latest_matches_scope_identity, text_matches_scope_identity}; const GENERATION_PUBLICATION_CHANNEL_CAPACITY: usize = 128; const TEXT_PROJECTION_DOCUMENTS_PER_PASS_V1: usize = 64; @@ -361,6 +361,7 @@ pub(super) struct MountedCodeIndexWorktreeV1 { Option>, pub(super) scheduler: Arc>, pub(super) serving_generation: Arc>>, + pub(super) text_generation: Arc>>, /// Immutable progress snapshot independently readable while the scheduler /// owns a long reconcile or text-artifact transaction. pub(super) build_progress: super::CodeIndexBuildProgressSlotV1, @@ -480,6 +481,34 @@ fn dashboard_freshness_identity( identity } +fn dashboard_text_freshness_identity( + latest: Option<&LatestCodeTextGenerationV1>, +) -> crate::dashboard::code_index_freshness_api::CodeIndexWorktreeFreshnessV1 { + let mut identity = + crate::dashboard::code_index_freshness_api::CodeIndexWorktreeFreshnessV1::default(); + if let Some(latest) = latest { + let metadata = latest.metadata(); + let snapshot = metadata.snapshot(); + identity.repository_id = Some(snapshot.repository.as_str().to_owned()); + identity.worktree_id = snapshot + .worktree + .as_ref() + .map(|worktree| worktree.as_str().to_owned()); + identity.source_reference = snapshot + .reference + .as_ref() + .map(|reference| reference.as_str().to_owned()); + identity.source_revision = snapshot + .source_revision + .as_ref() + .map(|revision| revision.as_str().to_owned()); + identity.latest_generation_id = Some(metadata.manifest().generation_id.as_str().to_owned()); + identity.snapshot_content_identity = Some(snapshot.content_identity.as_str().to_owned()); + identity.sealed_at_micros = Some(metadata.manifest().seal.sealed_at.0); + } + identity +} + pub(in crate::daemon) struct CodeIndexSemanticEvaluationPublicationLeaseV1 { _guard: tokio::sync::OwnedMutexGuard<()>, } @@ -1501,6 +1530,10 @@ impl CodeIndexSchedulerRegistryV1 { .write() .unwrap_or_else(std::sync::PoisonError::into_inner); *serving = None; + *worktree + .text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; worktree .serving_generation_epoch .fetch_add(1, Ordering::AcqRel); @@ -1754,12 +1787,14 @@ impl CodeIndexSchedulerRegistryV1 { async fn seat_retained_serving_generation( scheduler: &Arc>, serving_generation: &Arc>>, + text_generation: &Arc>>, serving_generation_epoch: &Arc, wake: &Arc, - retained: LatestCompleteCodeIndexV1, + mut retained: LatestCompleteCodeIndexV1, ) { let swap_scheduler = Arc::clone(scheduler); let swap_serving = Arc::clone(serving_generation); + let swap_text = Arc::clone(text_generation); let swap_serving_epoch = Arc::clone(serving_generation_epoch); let seated = tokio::task::spawn_blocking(move || { let scheduler = swap_scheduler @@ -1769,6 +1804,18 @@ impl CodeIndexSchedulerRegistryV1 { .active_publication_matches(&retained) .unwrap_or(false) { + let mut text = swap_text + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = text.as_ref() + && existing.metadata().manifest().generation_id + == retained.generation().manifest().generation_id + { + retained.text = existing.clone(); + } else { + *text = Some(retained.text_generation_handle()); + } + drop(text); let mut serving = swap_serving .write() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -2234,6 +2281,8 @@ impl CodeIndexSchedulerRegistryV1 { // claims freshness; missing Git authority still leaves this empty. let serving_generation: Arc>> = Arc::new(RwLock::new(None)); + let text_generation: Arc>> = + Arc::new(RwLock::new(None)); let serving_generation_epoch = Arc::new(AtomicU64::new(0)); let serving_generation_installation = Arc::new(Mutex::new(None)); let hints = Arc::clone(&opened.hints); @@ -2250,6 +2299,7 @@ impl CodeIndexSchedulerRegistryV1 { let worker_scheduler = Arc::clone(&scheduler); let worker_reconcile_in_progress = Arc::clone(&reconcile_in_progress); let worker_serving_generation = Arc::clone(&serving_generation); + let worker_text_generation = Arc::clone(&text_generation); let worker_serving_generation_epoch = Arc::clone(&serving_generation_epoch); let worker_wake = Arc::clone(&wake); let worker_pending_wake = Arc::clone(&pending_wake); @@ -2356,7 +2406,7 @@ impl CodeIndexSchedulerRegistryV1 { // never misreads in-flight owner work as plain unavailability. let _reconcile_pass = super::ReconcilePassGuard::enter(&worker_reconcile_in_progress); - let text_generation = serving_generation + let text_generation = worker_text_generation .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); @@ -2404,6 +2454,28 @@ impl CodeIndexSchedulerRegistryV1 { #[cfg(feature = "hotpath")] hotpath::gauge!("query.artifact.slice.yield_to_reconcile_total").inc(1_u64); } + if worker_text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() + { + let text_scheduler = Arc::clone(&scheduler); + let retained_text = tokio::task::spawn_blocking(move || { + text_scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .servable_retained_text_generation() + }) + .await; + if let Ok(Some(retained_text)) = retained_text { + *worker_text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(retained_text); + worker_wake.notify_one(); + continue; + } + } // Admission is held: queue wait ends and service time begins. let started_micros = now_micros().0; let (arrival, trigger) = Self::take_pending_arrival( @@ -2461,6 +2533,7 @@ impl CodeIndexSchedulerRegistryV1 { Self::seat_retained_serving_generation( &scheduler, &serving_generation, + &worker_text_generation, &serving_generation_epoch, &worker_wake, retained, @@ -2500,6 +2573,7 @@ impl CodeIndexSchedulerRegistryV1 { Self::seat_retained_serving_generation( &scheduler, &serving_generation, + &worker_text_generation, &serving_generation_epoch, &worker_wake, retained, @@ -2610,6 +2684,7 @@ impl CodeIndexSchedulerRegistryV1 { let scheduler = Arc::clone(&worker_scheduler); let serving_generation = Arc::clone(&worker_serving_generation); let serving_generation_epoch = Arc::clone(&worker_serving_generation_epoch); + let text_generation = Arc::clone(&worker_text_generation); let text_latest = latest.clone(); let latest = latest.clone(); let serving_swap = tokio::task::spawn_blocking(move || { @@ -2628,6 +2703,10 @@ impl CodeIndexSchedulerRegistryV1 { .unwrap_or_else(std::sync::PoisonError::into_inner); *serving = Some(latest.clone()); serving_generation_epoch.fetch_add(1, Ordering::AcqRel); + *text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(latest.text_generation_handle()); } // Semantic admission is independently retryable. A // prior attempt may have lost bounded queue capacity, @@ -2733,6 +2812,7 @@ impl CodeIndexSchedulerRegistryV1 { semantic_vector_graph_provider: None, scheduler, serving_generation, + text_generation, build_progress, serving_generation_epoch, serving_generation_installation, @@ -3303,16 +3383,25 @@ impl CodeIndexSchedulerRegistryV1 { // so one warmup/dashboard call during a rebuild parked a runtime worker // for the reconcile's whole duration AND serialized every code-index // query behind it: a silent, daemon-wide code-index outage. - let serving = { + let (serving, text) = { let mounted = self.mounted.lock().await; let worktree = mounted.get(&project_root)?; - Arc::clone(&worktree.serving_generation) + ( + Arc::clone(&worktree.serving_generation), + Arc::clone(&worktree.text_generation), + ) }; let latest = serving .read() .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone()?; - Some(latest.generation.manifest().generation_id.clone()) + .clone(); + if let Some(latest) = latest { + return Some(latest.generation.manifest().generation_id.clone()); + } + text.read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|latest| latest.metadata().manifest().generation_id.clone()) } /// Exact bounded dashboard projection for one mounted worktree. @@ -3327,13 +3416,14 @@ impl CodeIndexSchedulerRegistryV1 { project_root: &Path, ) -> Option { let canonical_root = project_root.canonicalize().ok()?; - let (scheduler, reconcile_in_progress, serving_generation, build_progress) = { + let (scheduler, reconcile_in_progress, serving_generation, text_generation, build_progress) = { let mounted = self.mounted.lock().await; let worktree = mounted.get(&canonical_root)?; ( Arc::clone(&worktree.scheduler), Arc::clone(&worktree.reconcile_in_progress), Arc::clone(&worktree.serving_generation), + Arc::clone(&worktree.text_generation), Arc::clone(&worktree.build_progress), ) }; @@ -3364,6 +3454,15 @@ impl CodeIndexSchedulerRegistryV1 { .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + let text = text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let identity = if latest.is_some() { + dashboard_freshness_identity(latest.as_ref()) + } else { + dashboard_text_freshness_identity(text.as_ref()) + }; return crate::dashboard::code_index_freshness_api::CodeIndexWorktreeFreshnessV1 { worktree_root: canonical_root.display().to_string(), last_reconcile_micros: None, @@ -3378,7 +3477,7 @@ impl CodeIndexSchedulerRegistryV1 { hook_hint_count: None, coverage: "partial_refresh_in_progress".to_owned(), progress, - ..dashboard_freshness_identity(latest.as_ref()) + ..identity }; } }; @@ -3388,6 +3487,10 @@ impl CodeIndexSchedulerRegistryV1 { .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + let text = text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); let hook_hint_count = scheduler.pending_hint_count(); let staleness_state = if refreshing { if latest.is_some() { @@ -3406,6 +3509,11 @@ impl CodeIndexSchedulerRegistryV1 { } else { "indexing" }; + let identity = if latest.is_some() { + dashboard_freshness_identity(latest.as_ref()) + } else { + dashboard_text_freshness_identity(text.as_ref()) + }; crate::dashboard::code_index_freshness_api::CodeIndexWorktreeFreshnessV1 { worktree_root: canonical_root.display().to_string(), last_reconcile_micros: scheduler.last_reconciled_at_micros(), @@ -3420,7 +3528,7 @@ impl CodeIndexSchedulerRegistryV1 { } .to_owned(), progress, - ..dashboard_freshness_identity(latest.as_ref()) + ..identity } }) .await @@ -3760,6 +3868,27 @@ impl CodeIndexSchedulerRegistryV1 { /// never blocks on reconcile, gix status, or the scheduler mutex. A caller /// that takes this arm is serving an older complete generation and must /// mark its lanes stale; it must never present the result as current. + pub(in crate::daemon) async fn latest_text_serving_for_scope( + &self, + scope: &tracedecay_application::ResolvedScope, + ) -> Option { + let text_generation = { + let mounted = self.mounted.lock().await; + Arc::clone( + &unique_mounted_for_scope(&mounted, scope) + .unique()? + .1 + .text_generation, + ) + }; + let latest = text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone()?; + (text_matches_scope_identity(&latest, scope) && latest.text_serving_is_ready()) + .then_some(latest) + } + pub(in crate::daemon) async fn latest_complete_serving_for_scope( &self, scope: &tracedecay_application::ResolvedScope, diff --git a/src/daemon/code_index_scheduler/registry/scope_identity.rs b/src/daemon/code_index_scheduler/registry/scope_identity.rs index 1a259b6d0a..c13ce8d96d 100644 --- a/src/daemon/code_index_scheduler/registry/scope_identity.rs +++ b/src/daemon/code_index_scheduler/registry/scope_identity.rs @@ -2,7 +2,18 @@ use tracedecay_application::ResolvedScope; -use super::super::LatestCompleteCodeIndexV1; +use super::super::{LatestCodeTextGenerationV1, LatestCompleteCodeIndexV1}; + +pub(in crate::daemon::code_index_scheduler) fn text_matches_scope_identity( + latest: &LatestCodeTextGenerationV1, + scope: &ResolvedScope, +) -> bool { + let metadata = latest.metadata(); + scope.validate().is_ok() + && metadata.manifest().project_id == scope.project_id + && metadata.snapshot().repository == scope.repository_id + && metadata.snapshot().worktree.as_ref() == Some(&scope.worktree_id) +} /// The serving scope gate: project, repository, and worktree must equal the /// admitted scope's checkout identity. It admits only canonical scope digests. diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 61dc3c5543..b95c573a02 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -2540,6 +2540,66 @@ fn production_text_serving_builds_publishes_and_reopens_the_artifact_head() { ); } +#[test] +fn retained_text_generation_reaches_query_owners_without_full_sealed_decode() { + let fixture = GitFixture::new(&[( + "src/lib.rs", + "pub fn caller() { callee(); }\npub fn callee() {}\n", + )]); + let store = TempDir::new().expect("store root"); + { + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("seed generation")); + } + + let mut reopened = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + assert_eq!(reopened.sealed_decode_count(), 0); + let text = reopened + .servable_retained_text_generation() + .expect("active durable text generation"); + let scan = build_progress_snapshot(&reopened); + assert_eq!(scan.phase, CodeIndexBuildPhaseV1::SourceScan); + assert!(scan.total_lexical_bytes > 0); + assert_eq!(scan.completed_lexical_bytes, scan.total_lexical_bytes); + assert_eq!( + reopened.sealed_decode_count(), + 0, + "binding authenticated text metadata must not decode the full generation" + ); + while !text + .advance_text_serving(64) + .expect("advance retained text generation") + {} + assert!(text.query_owners_are_warm()); + assert_eq!( + reopened.sealed_decode_count(), + 0, + "exact and lexical owners must not require the full generation" + ); + + fixture.edit( + "src/lib.rs", + "pub fn caller() { replacement(); }\npub fn replacement() {}\n", + ); + published( + reopened + .reconcile_now() + .expect("publish superseding generation"), + ); + assert!(matches!( + text.advance_text_serving(1), + Err(tracedecay_query::retrieval::RetrievalPortError::Cancelled) + )); +} + #[test] fn text_artifact_publication_serializes_pointer_attachment_with_retention() { use std::sync::{Condvar, Mutex, mpsc}; @@ -2643,7 +2703,7 @@ fn text_artifact_publication_serializes_pointer_attachment_with_retention() { let publisher = thread::spawn(move || { artifact_store.publish( &staging, - &generation, + &generation.manifest().generation_id, &sealed_identity, publish_control.as_ref(), ) @@ -2769,7 +2829,12 @@ fn text_artifact_publish_rejects_a_permissive_artifacts_root() { assert!( matches!( - artifact_store.publish(&staging, generation, &sealed_identity, &NeverCancelled), + artifact_store.publish( + &staging, + &generation.manifest().generation_id, + &sealed_identity, + &NeverCancelled, + ), Err(tracedecay_query::retrieval::RetrievalPortError::Contract(_)) ), "publication must fail closed instead of accepting a permissive artifact namespace" @@ -2816,7 +2881,12 @@ fn text_artifact_publish_rejects_a_symlink_artifacts_root() { assert!( matches!( - artifact_store.publish(&staging, generation, &sealed_identity, &NeverCancelled), + artifact_store.publish( + &staging, + &generation.manifest().generation_id, + &sealed_identity, + &NeverCancelled, + ), Err(tracedecay_query::retrieval::RetrievalPortError::Contract(_)) ), "publication must not traverse a symlink artifact namespace" @@ -9958,9 +10028,10 @@ async fn same_root_remount_updates_retained_graph_policy_before_worker_activatio tokio::time::sleep(Duration::from_millis(10)).await; }; assert!( - !latest.query_owners_are_warm(), - "same-root refusal was lost and entered retained owner hydration" + latest.query_owners_are_warm(), + "same-root graph refusal must preserve retained text hydration" ); + assert!(latest.production_query_owners().is_ok()); assert!(latest.production_graph_serving().is_err()); registry.shutdown().await; } @@ -9979,17 +10050,28 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese store.path(), &fixture.path().canonicalize().expect("canonical fixture"), ); - let sealed_worktree_id = { + let (scope, sealed_worktree_id, sealed_generation_id) = { let mut scheduler = scheduler(&fixture, scoped_store.clone(), bytes); published(scheduler.reconcile_now().expect("seed generation")); - scheduler + let latest = scheduler .latest_complete() - .expect("seeded generation") - .generation - .snapshot() + .expect("seeded generation"); + let snapshot = latest.generation.snapshot(); + let worktree_id = snapshot .worktree .clone() - .expect("seeded worktree id") + .expect("seeded worktree id"); + ( + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + worktree_id.clone(), + snapshot.reference.clone(), + ) + .expect("resolved scope"), + worktree_id, + latest.generation.manifest().generation_id.clone(), + ) }; // Change the worktree so a reconcile pass would seal a brand-new // generation if the worker fell through after the activation failure. @@ -10032,10 +10114,32 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese 1, "a retryable activation failure must not seal a duplicate generation" ); + let text_deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if registry + .latest_text_serving_for_scope(&scope) + .await + .is_some() + { + break; + } + assert!( + std::time::Instant::now() <= text_deadline, + "graph retry backoff withheld the retained text generation" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } assert_eq!( registry.latest_generation_id(fixture.path()).await, - None, - "a generation that never activated must not serve" + Some(sealed_generation_id), + "exact and lexical serving remains authoritative during graph retry" + ); + assert!( + registry + .latest_complete_serving_for_scope(&scope) + .await + .is_none(), + "retryable graph activation must not expose an unactivated graph owner" ); // Clearing the injected failure lets the scheduled backoff retry activate @@ -10044,7 +10148,7 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { if registry - .latest_generation_id(fixture.path()) + .latest_complete_serving_for_scope(&scope) .await .is_some() { diff --git a/src/daemon/project_open_owners/query_authority_upgrade.rs b/src/daemon/project_open_owners/query_authority_upgrade.rs index 149bef9d88..c6b34a2eeb 100644 --- a/src/daemon/project_open_owners/query_authority_upgrade.rs +++ b/src/daemon/project_open_owners/query_authority_upgrade.rs @@ -1,13 +1,14 @@ //! Deferred query-authority mounts covering the cold-open generation gap. //! //! Both project-open query-authority mounts resolve the privacy domain from an -//! already-complete current code generation, but a fresh project publishes its +//! authenticated current text generation, but a fresh project publishes its //! first generation asynchronously after admission, so the open-time mount can //! lose that race. A physical restart restores a sealed generation as `Noop` -//! and does not republish, so waiters must also poll the serving slot: a -//! publication that will never repeat must not deny search for the rest of the -//! daemon session. Retry the exact mount that failed when this project's -//! generation becomes serving, mirroring the deferred feedback-cycle upgrade. +//! and does not republish, so waiters must also poll the text-serving slot: a +//! publication that will never repeat must not deny exact/lexical search while +//! optional graph activation is still warming. Retry the exact mount that +//! failed when this project's text generation becomes serving, mirroring the +//! deferred feedback-cycle upgrade. use std::path::{Path, PathBuf}; use tracedecay_application::ResolvedScope; @@ -29,9 +30,9 @@ pub(super) enum DeferredQueryAuthorityMountV1 { }, } -/// Waits for the first complete code-index generation of `project_root` and -/// then retries the query-authority mount. Exits when the mount reaches any -/// terminal outcome or the publication channel closes (daemon shutdown). +/// Waits for the first authenticated text generation of `project_root` and then +/// retries the query-authority mount. Exits when the mount reaches any terminal +/// outcome or the publication channel closes (daemon shutdown). /// /// The open-time mount runs before code-index activation, so the first ready /// check usually misses. A later `Published` event wakes the waiter on a @@ -53,7 +54,7 @@ pub(super) fn spawn_deferred_query_authority_mount( loop { if invocation .code_index_schedulers - .latest_complete_ready_for_scope(&scope) + .latest_text_serving_for_scope(&scope) .await .is_some() && try_deferred_mount(&invocation, &project_root, &scope, &mount).await From 7670a8be7469ceb63a0dde3111dd98e09fd5a2c4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 04:39:13 +0000 Subject: [PATCH 33/69] perf(index): share physical generation artifacts --- .../src/production/helpers.rs | 49 +++++--- .../src/production/import_evidence.rs | 9 +- .../src/production/mod.rs | 67 ++++++---- .../src/production/sealed_codec.rs | 4 +- .../src/production/worker_tests.rs | 16 ++- .../production_orchestration.rs | 114 +++++++++++++++++- 6 files changed, 202 insertions(+), 57 deletions(-) diff --git a/crates/tracedecay-code-index/src/production/helpers.rs b/crates/tracedecay-code-index/src/production/helpers.rs index 1b8bfbe49b..4bbe33874d 100644 --- a/crates/tracedecay-code-index/src/production/helpers.rs +++ b/crates/tracedecay-code-index/src/production/helpers.rs @@ -1,7 +1,7 @@ use super::*; pub(crate) struct StagedGenerationV1 { - pub(crate) files: Vec, + pub(crate) files: Vec>, pub(crate) chunks: GenerationChunkManifestV1, pub(crate) symbols: GenerationSymbolIndexV1, pub(crate) lineage: Vec, @@ -9,7 +9,7 @@ pub(crate) struct StagedGenerationV1 { pub(crate) fn staged_generation( generation_id: CodeGenerationId, - mut files: Vec, + mut files: Vec>, lineage: Vec, ) -> Result { files.sort_by(|left, right| { @@ -19,20 +19,26 @@ pub(crate) fn staged_generation( .file_occurrence_id .cmp(&right.artifacts.chunks.document.file_occurrence_id) }); - let chunks = GenerationChunkManifestV1::new( - generation_id.clone(), - files - .iter() - .map(|file| file.artifacts.chunks.clone()) - .collect(), + let chunks = hotpath::measure_block!( + "code_index.generation.aggregate_chunks", + GenerationChunkManifestV1::new( + generation_id.clone(), + files + .iter() + .map(|file| file.artifacts.chunks.clone()) + .collect(), + ) ) .map_err(CodeIndexProductionErrorV1::Increment)?; - let symbols = GenerationSymbolIndexV1::new( - generation_id, - files - .iter() - .flat_map(|file| file.artifacts.symbols.clone()) - .collect(), + let symbols = hotpath::measure_block!( + "code_index.generation.aggregate_symbols", + GenerationSymbolIndexV1::new( + generation_id, + files + .iter() + .flat_map(|file| file.artifacts.symbols.clone()) + .collect(), + ) ) .map_err(CodeIndexProductionErrorV1::Lineage)?; Ok(StagedGenerationV1 { @@ -114,7 +120,7 @@ pub(crate) fn captured_files( pub(crate) fn coverage_summary( snapshot: &SanitizedCodeSnapshotV1, - files: &[FileGenerationArtifactsV1], + files: &[Arc], ) -> CoverageSummaryV1 { let mut coverage = CoverageSummaryV1::default(); for file in &snapshot.files { @@ -196,17 +202,20 @@ pub(crate) fn edge_order( )) } -pub(crate) fn collect_edge_evidence( - files: &[FileGenerationArtifactsV1], -) -> (Vec, Vec) { +pub(crate) fn collect_edge_evidence( + files: &[T], +) -> (Vec, Vec) +where + T: AsRef, +{ let mut edges = files .iter() - .flat_map(|file| file.artifacts.edges.clone()) + .flat_map(|file| file.as_ref().artifacts.edges.clone()) .collect::>(); edges.sort_by(edge_order); let mut abstentions = files .iter() - .flat_map(|file| file.artifacts.edge_abstentions.clone()) + .flat_map(|file| file.as_ref().artifacts.edge_abstentions.clone()) .collect::>(); abstentions.sort(); (edges, abstentions) diff --git a/crates/tracedecay-code-index/src/production/import_evidence.rs b/crates/tracedecay-code-index/src/production/import_evidence.rs index d1f137e104..85db7fbf75 100644 --- a/crates/tracedecay-code-index/src/production/import_evidence.rs +++ b/crates/tracedecay-code-index/src/production/import_evidence.rs @@ -2,10 +2,11 @@ use crate::chunks::CodeIndexImportEvidenceV1; use super::{CodeIndexProductionErrorV1, FileGenerationArtifactsV1}; -pub(super) fn derive_import_evidence( - files: &[FileGenerationArtifactsV1], -) -> Vec { - derive_import_evidence_from(files.iter()) +pub(super) fn derive_import_evidence(files: &[T]) -> Vec +where + T: AsRef, +{ + derive_import_evidence_from(files.iter().map(AsRef::as_ref)) } fn derive_import_evidence_from<'a>( diff --git a/crates/tracedecay-code-index/src/production/mod.rs b/crates/tracedecay-code-index/src/production/mod.rs index cf645b623b..8b4965280c 100644 --- a/crates/tracedecay-code-index/src/production/mod.rs +++ b/crates/tracedecay-code-index/src/production/mod.rs @@ -123,7 +123,10 @@ impl CodeIndexProductionConfigV1 { #[derive(Clone, Debug, PartialEq, Eq)] pub struct CodeIndexCapturedFileV1 { pub file_occurrence_id: FileOccurrenceId, - pub sanitized_bytes: Vec, + /// Canonical sanitized source allocation retained by the snapshot while + /// production reads it. Domain intake materializes only bounded per-file + /// `Vec` inputs where its serializable contract requires ownership. + pub sanitized_bytes: Arc<[u8]>, pub sensitivity_level: SensitivityLevelV1, } @@ -262,10 +265,10 @@ struct FileGenerationArtifactsV1 { } enum IncrementFileMaterializationV1 { - CarryForward(FileGenerationArtifactsV1), + CarryForward(Arc), ReExtracted { reuse_key: ManifestDigest, - artifact: FileGenerationArtifactsV1, + artifact: Arc, fallback: bool, }, Deleted, @@ -342,11 +345,15 @@ where pub struct PhysicalCodeArtifactPoolStatsV1 { pub inserted: u64, pub reused: u64, + /// Artifact allocations still owned by a published or staged generation. + /// The physical pool indexes these allocations weakly and never extends + /// their lifetime. + pub resident: u64, } #[derive(Default)] struct PhysicalCodeArtifactPoolStateV1 { - artifacts: BTreeMap>, + artifacts: BTreeMap>, insertion_order: VecDeque, inserted: u64, reused: u64, @@ -361,14 +368,14 @@ pub struct SharedPhysicalCodeArtifactPoolV1 { } #[hotpath::measure] -fn clone_arc_under_lock( +fn upgrade_weak_under_lock( state: &Mutex, - select: impl FnOnce(&S) -> Option>, + select: impl FnOnce(&S) -> Option>, ) -> Option> { let state = state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - select(&state) + select(&state).and_then(|value| value.upgrade()) } impl SharedPhysicalCodeArtifactPoolV1 { @@ -378,12 +385,12 @@ impl SharedPhysicalCodeArtifactPoolV1 { key: &ManifestDigest, file: &ReceiptBoundCodeFileV1, worker: &crate::hotpath_observe::WorkerBusyGuard, - ) -> Option { + ) -> Option> { let artifact = { let _coordination = worker.pool_coordination(); - clone_arc_under_lock(&self.state, |state| state.artifacts.get(key).cloned()) + upgrade_weak_under_lock(&self.state, |state| state.artifacts.get(key).cloned()) }?; - let rebound = artifact.rematerialize_for_file(file).ok()?; + let rebound = Arc::new(artifact.rematerialize_for_file(file).ok()?); { let _coordination = worker.pool_coordination(); let mut state = self @@ -395,16 +402,17 @@ impl SharedPhysicalCodeArtifactPoolV1 { Some(rebound) } - /// Record one artifact under its physical reuse key. The artifact is - /// cloned only when the key is actually admitted, so re-recording an - /// already-pooled key (every warm rebuild) costs a lock, not a deep copy. + /// Record one generation-owned artifact under its physical reuse key. + /// The pool retains only a weak index entry, so indexing a cold generation + /// never deep-clones or pins the parsed/chunked payload. #[hotpath::measure] - fn insert(&self, key: ManifestDigest, artifact: &FileGenerationArtifactsV1) { + fn insert(&self, key: ManifestDigest, artifact: &Arc) { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if state.artifacts.contains_key(&key) { + if let Some(retained) = state.artifacts.get_mut(&key) { + *retained = Arc::downgrade(artifact); return; } while state.artifacts.len() >= MAX_PHYSICAL_CODE_ARTIFACTS { @@ -414,7 +422,7 @@ impl SharedPhysicalCodeArtifactPoolV1 { state.artifacts.remove(&evicted); } state.insertion_order.push_back(key.clone()); - state.artifacts.insert(key, Arc::new(artifact.clone())); + state.artifacts.insert(key, Arc::downgrade(artifact)); state.inserted = state.inserted.saturating_add(1); } @@ -426,6 +434,14 @@ impl SharedPhysicalCodeArtifactPoolV1 { PhysicalCodeArtifactPoolStatsV1 { inserted: state.inserted, reused: state.reused, + resident: u64::try_from( + state + .artifacts + .values() + .filter(|artifact| artifact.strong_count() > 0) + .count(), + ) + .unwrap_or(u64::MAX), } } } @@ -466,7 +482,7 @@ pub struct CodeIndexPublishedGenerationV1 { snapshot: SanitizedCodeSnapshotV1, repository_parse_identity: CodeIndexRepositoryParseIdentityV1, ignored_source_roster: IgnoredSourceRosterV1, - files: Vec, + files: Vec>, chunks: GenerationChunkManifestV1, symbols: GenerationSymbolIndexV1, lineage: Vec, @@ -966,7 +982,7 @@ impl CodeIndexPublishedGenerationV1 { .validate() .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string()))?; - let mut files = self.files.iter().collect::>(); + let mut files = self.files.iter().map(Arc::as_ref).collect::>(); files.sort_by(|left, right| { left.artifacts .chunks @@ -1513,7 +1529,7 @@ where captured_files: &BTreeMap, control: &dyn CodeIndexExecutionControlV1, worker: &crate::hotpath_observe::WorkerBusyGuard, - ) -> Result<(ManifestDigest, FileGenerationArtifactsV1), CodeIndexProductionErrorV1> { + ) -> Result<(ManifestDigest, Arc), CodeIndexProductionErrorV1> { Self::checkpoint(control)?; let captured = captured_files .get(&file.file_occurrence_id) @@ -1526,7 +1542,7 @@ where generation_id: manifest.generation_id.clone(), file: file.clone(), snapshot_digest: capability.snapshot().intake_digest.clone(), - sanitized_bytes: captured.sanitized_bytes.clone(), + sanitized_bytes: captured.sanitized_bytes.to_vec(), }, ) .map_err(CodeIndexProductionErrorV1::Intake)?; @@ -1624,12 +1640,12 @@ where })?; Self::checkpoint(control)?; let (authority, extraction, _) = extraction.into_parts(); - let artifact = FileGenerationArtifactsV1 { + let artifact = Arc::new(FileGenerationArtifactsV1 { authority, extraction, artifacts, exact_authority, - }; + }); Ok((physical_reuse_key, artifact)) } @@ -1644,6 +1660,7 @@ where PHYSICAL_CODE_ARTIFACT_REUSE_DIGEST_DOMAIN, &config.project_id, &config.repository, + &file.file_occurrence_id, &file.logical_path, &file.content_digest, descriptor, @@ -1781,13 +1798,15 @@ where generation_id: manifest.generation_id.clone(), file: (**current_file).clone(), snapshot_digest: capability.snapshot().intake_digest.clone(), - sanitized_bytes: captured.sanitized_bytes.clone(), + sanitized_bytes: captured.sanitized_bytes.to_vec(), }, ) .map_err(CodeIndexProductionErrorV1::Intake)?; if let Ok(artifact) = prior.rematerialize_for_file(&receipt_bound) { crate::hotpath_observe::add_reused_parses(1); - Ok(IncrementFileMaterializationV1::CarryForward(artifact)) + Ok(IncrementFileMaterializationV1::CarryForward(Arc::new( + artifact, + ))) } else { // Opaque exact evidence may refuse generation-local // occurrence rebinding. Re-extract through the parser diff --git a/crates/tracedecay-code-index/src/production/sealed_codec.rs b/crates/tracedecay-code-index/src/production/sealed_codec.rs index 109019deda..c086c62557 100644 --- a/crates/tracedecay-code-index/src/production/sealed_codec.rs +++ b/crates/tracedecay-code-index/src/production/sealed_codec.rs @@ -644,12 +644,12 @@ impl CodeIndexPublishedGenerationV1 { for file in persisted_files { let exact_authority = ExactExtractionAuthorityV1::restore(&file.artifacts.chunks) .map_err(CodeIndexProductionErrorV1::Chunk)?; - files.push(FileGenerationArtifactsV1 { + files.push(Arc::new(FileGenerationArtifactsV1 { authority: file.authority, extraction: file.extraction, artifacts: file.artifacts, exact_authority, - }); + })); } let chunks = GenerationChunkManifestV1::new( manifest.generation_id.clone(), diff --git a/crates/tracedecay-code-index/src/production/worker_tests.rs b/crates/tracedecay-code-index/src/production/worker_tests.rs index 67acba47f1..4bd693c715 100644 --- a/crates/tracedecay-code-index/src/production/worker_tests.rs +++ b/crates/tracedecay-code-index/src/production/worker_tests.rs @@ -1,5 +1,5 @@ use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Weak}; use super::*; @@ -16,16 +16,22 @@ impl From for WorkerTestError { } #[test] -fn cloned_arc_lookup_releases_the_mutex_before_downstream_work() { - let state = Mutex::new(Some(Arc::new(7_u8))); +fn upgraded_weak_lookup_releases_the_mutex_before_downstream_work() { + let owner = Arc::new(7_u8); + let state = Mutex::new(Some(Arc::downgrade(&owner))); - let value = clone_arc_under_lock(&state, |value| value.clone()).expect("pooled value"); + let value = upgrade_weak_under_lock(&state, |value| value.clone()).expect("pooled value"); + assert!( + Arc::ptr_eq(&owner, &value), + "weak cache lookup must recover the generation's exact allocation" + ); let unlocked = state .try_lock() .expect("pooled lookup must not retain the mutex"); assert_eq!(*value, 7); - assert_eq!(unlocked.as_deref(), Some(&7)); + let retained = unlocked.as_ref().and_then(Weak::upgrade); + assert_eq!(retained.as_deref(), Some(&7)); } #[test] diff --git a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs index 7eae528956..c673c5863c 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs @@ -21,8 +21,9 @@ use tracedecay_code_index::{ CodeIndexProductionConfigV1, CodeIndexProductionErrorV1, CodeIndexProductionOwnerV1, CodeIndexPublicationStoreErrorV1, CodeIndexPublishedGenerationV1, CodeIndexRepositoryParseIdentityV1, SEALED_GENERATION_FORMAT_REVISION_V1, - VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, - VerifiedSealedLexicalPageV1, sealed_generation_payload_digest, + SharedPhysicalCodeArtifactPoolV1, VerifiedSealedLexicalPageReadV1, + VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, + sealed_generation_payload_digest, }, projection::{ ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, @@ -488,6 +489,115 @@ fn unchanged_increment_does_not_reextract_carried_files() { assert_eq!(after_restored.noop_parses, 0); } +/// The physical reuse pool is an index over immutable generation-owned +/// artifacts, not a second owner of every parsed and chunked payload. Keeping +/// the registry-scoped pool alive after its publication owner shuts down must +/// therefore release the complete file corpus with the generation. +#[test] +fn physical_artifact_pool_does_not_retain_a_dropped_generation() { + let pool = SharedPhysicalCodeArtifactPoolV1::default(); + { + let store = SharedPublicationStore::default(); + let mut owner = CodeIndexProductionOwnerV1::new(config(), store, ApplyingProjectionSink) + .expect("production owner") + .with_physical_artifact_pool(pool.clone()); + let generation = owner + .build_and_publish(request("file.physical.1", 1_100_000), &ActiveControl) + .expect("generation publishes"); + + assert_eq!(pool.stats().resident, 1); + drop(generation); + } + + assert_eq!( + pool.stats().resident, + 0, + "the reuse index must not pin a second copy of a dropped generation" + ); +} + +#[test] +fn physical_artifact_reuse_preserves_byte_exact_sealed_generation() { + let pool = SharedPhysicalCodeArtifactPoolV1::default(); + let mut source_owner = CodeIndexProductionOwnerV1::new( + config(), + SharedPublicationStore::default(), + ApplyingProjectionSink, + ) + .expect("source owner") + .with_physical_artifact_pool(pool.clone()); + let source = source_owner + .build_and_publish(request("file.physical.target", 1_100_000), &ActiveControl) + .expect("source generation publishes"); + + let mut reused_owner = CodeIndexProductionOwnerV1::new( + config(), + SharedPublicationStore::default(), + ApplyingProjectionSink, + ) + .expect("reuse owner") + .with_physical_artifact_pool(pool.clone()); + let reused = reused_owner + .build_and_publish(request("file.physical.target", 1_200_000), &ActiveControl) + .expect("physically reused generation publishes"); + + let mut cold_owner = CodeIndexProductionOwnerV1::new( + config(), + SharedPublicationStore::default(), + ApplyingProjectionSink, + ) + .expect("cold owner"); + let cold = cold_owner + .build_and_publish(request("file.physical.target", 1_200_000), &ActiveControl) + .expect("cold comparison generation publishes"); + + let mut foreign_owner = CodeIndexProductionOwnerV1::new( + config(), + SharedPublicationStore::default(), + ApplyingProjectionSink, + ) + .expect("foreign occurrence owner") + .with_physical_artifact_pool(pool.clone()); + foreign_owner + .build_and_publish(request("file.physical.foreign", 1_200_000), &ActiveControl) + .expect("foreign occurrence generation publishes without unsafe reuse"); + + assert_eq!( + pool.stats().reused, + 1, + "only the byte-exact file occurrence may reuse physical artifacts" + ); + assert_eq!(reused.manifest(), cold.manifest(), "manifest mismatch"); + assert_eq!(reused.snapshot(), cold.snapshot(), "snapshot mismatch"); + assert_eq!(reused.chunks(), cold.chunks(), "chunk mismatch"); + assert_eq!(reused.symbols(), cold.symbols(), "symbol mismatch"); + assert_eq!(reused.lineage(), cold.lineage(), "lineage mismatch"); + assert_eq!(reused.imports(), cold.imports(), "import mismatch"); + assert_eq!(reused.edges(), cold.edges(), "edge mismatch"); + assert_eq!( + reused.edge_abstentions(), + cold.edge_abstentions(), + "edge abstention mismatch" + ); + assert_eq!(reused.coverage(), cold.coverage(), "coverage mismatch"); + assert_eq!( + reused.capability(), + cold.capability(), + "capability mismatch" + ); + assert_eq!( + reused.projection(), + cold.projection(), + "projection mismatch" + ); + assert_eq!( + reused.encode_sealed().expect("reused generation seals"), + cold.encode_sealed().expect("cold generation seals"), + "sharing the physical allocation must preserve every durable byte and digest" + ); + drop(source); +} + /// One file exceeding the bounded per-file parse budget must never fail the /// whole build: the generation still completes, publishes, and serves, with /// the slow file recorded as a typed unsupported document (with a reason) and From 5bf8838f05e37b8298fe8cd9e42204f79fef8e83 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 04:49:30 +0000 Subject: [PATCH 34/69] refactor(index): remove obsolete snapshot adjustment error --- src/daemon/code_index_scheduler.rs | 5 +---- .../code_index_scheduler/registry/ignored_dependencies.rs | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 0025122d3f..5a8cf1965a 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -38,7 +38,7 @@ use tracedecay_private_fs::{ }; use tracedecay_runtime_core::resident_memory::{ DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, ProcessResidentMemoryV1, - ResidentMemoryAdjustmentFailureV1, ResidentMemoryAdmissionFailureV1, + ResidentMemoryAdmissionFailureV1, ResidentMemoryComponentIdV1, ResidentMemoryKeyV1, ResidentMemoryReservationV1, }; use tracedecay_usecases::code_index::{ @@ -3387,8 +3387,6 @@ pub(super) enum CodeIndexSchedulerErrorV1 { SnapshotMemoryAdmission(ResidentMemoryAdmissionFailureV1), #[error("code-index retained-source resident-memory capacity is unavailable")] SnapshotMemoryCapacityUnavailable, - #[error("code-index retained-source resident-memory adjustment failed: {0}")] - SnapshotMemoryAdjustment(ResidentMemoryAdjustmentFailureV1), #[error("code-index worker plan refused: {0}")] WorkerPlan(#[from] tracedecay_code_index::parallelism::CodeIndexWorkerPlanInstallErrorV1), #[cfg(not(test))] @@ -3435,7 +3433,6 @@ impl CodeIndexSchedulerErrorV1 { | Self::WorkerMemoryAdmission(_) | Self::SnapshotMemoryAdmission(_) | Self::SnapshotMemoryCapacityUnavailable - | Self::SnapshotMemoryAdjustment(_) | Self::WorkerPlan(_) => false, #[cfg(not(test))] Self::WorkerPlanNotInstalled => false, diff --git a/src/daemon/code_index_scheduler/registry/ignored_dependencies.rs b/src/daemon/code_index_scheduler/registry/ignored_dependencies.rs index a440ab06be..e8d4510764 100644 --- a/src/daemon/code_index_scheduler/registry/ignored_dependencies.rs +++ b/src/daemon/code_index_scheduler/registry/ignored_dependencies.rs @@ -120,9 +120,6 @@ fn clone_scheduler_error(error: &CodeIndexSchedulerErrorV1) -> CodeIndexSchedule CodeIndexSchedulerErrorV1::SnapshotMemoryCapacityUnavailable => { CodeIndexSchedulerErrorV1::SnapshotMemoryCapacityUnavailable } - CodeIndexSchedulerErrorV1::SnapshotMemoryAdjustment(error) => { - CodeIndexSchedulerErrorV1::SnapshotMemoryAdjustment(*error) - } CodeIndexSchedulerErrorV1::WorkerPlan(error) => { CodeIndexSchedulerErrorV1::WorkerPlan(error.clone()) } From 056750eaf1bb2b8d4bcc92fe5b1e3f976932bf4b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 05:01:39 +0000 Subject: [PATCH 35/69] perf(hotpath): add bounded capture progress gauges --- crates/tracedecay-code-index/src/extract.rs | 2 - .../src/hotpath_observe.rs | 30 --- .../src/retained_parse.rs | 1 - .../code_index_scheduler/git_tree_capture.rs | 219 ++++++++++++++---- 4 files changed, 180 insertions(+), 72 deletions(-) diff --git a/crates/tracedecay-code-index/src/extract.rs b/crates/tracedecay-code-index/src/extract.rs index 8e6763fb1a..aa789a01d7 100644 --- a/crates/tracedecay-code-index/src/extract.rs +++ b/crates/tracedecay-code-index/src/extract.rs @@ -213,7 +213,6 @@ impl TreeSitterExtractor { } let authority = file.authority().clone(); let file = file.validated_file(); - crate::hotpath_observe::record_captured_bytes(file.sanitized_bytes.len() as u64); validate_descriptor(file, descriptor)?; let admitted_prefix = file .sanitized_bytes @@ -510,7 +509,6 @@ impl LanguageExtractor for TreeSitterExtractor { } let authority = file.authority().clone(); let file = file.validated_file(); - crate::hotpath_observe::record_captured_bytes(file.sanitized_bytes.len() as u64); validate_descriptor(file, descriptor)?; let parser = self.resolve_parser(file, descriptor).ok_or({ diff --git a/crates/tracedecay-code-index/src/hotpath_observe.rs b/crates/tracedecay-code-index/src/hotpath_observe.rs index efa49623ca..ad8757f2e2 100644 --- a/crates/tracedecay-code-index/src/hotpath_observe.rs +++ b/crates/tracedecay-code-index/src/hotpath_observe.rs @@ -225,36 +225,6 @@ pub(crate) fn record_files(count: usize) { } } -/// Record the bounded candidate byte total admitted to one parser or builder -/// operation. The key is static and the value is a current total rather than -/// a per-file series, so a large generation cannot create profiler entries. -#[inline(always)] -pub(crate) fn record_candidate_bytes(bytes: u64) { - #[cfg(feature = "hotpath")] - { - hotpath::gauge!("code_index_candidate_bytes").set(bytes); - } - #[cfg(not(feature = "hotpath"))] - { - let _ = bytes; - } -} - -/// Record the bounded sanitized bytes captured for the current source -/// operation. This is deliberately a gauge: it reports the latest admitted -/// boundary without accumulating one event per file. -#[inline(always)] -pub(crate) fn record_captured_bytes(bytes: u64) { - #[cfg(feature = "hotpath")] - { - hotpath::gauge!("code_index_captured_bytes").set(bytes); - } - #[cfg(not(feature = "hotpath"))] - { - let _ = bytes; - } -} - /// Every call site computes its byte total inside a `hotpath`-gated block, so /// this carries the same gate instead of a body that can never run. #[cfg(feature = "hotpath")] diff --git a/crates/tracedecay-code-index/src/retained_parse.rs b/crates/tracedecay-code-index/src/retained_parse.rs index 9dde0a7687..5458a83325 100644 --- a/crates/tracedecay-code-index/src/retained_parse.rs +++ b/crates/tracedecay-code-index/src/retained_parse.rs @@ -583,7 +583,6 @@ fn record_success( .extracted_bytes .saturating_add(extraction.metrics.visited_bytes as u64); } - crate::hotpath_observe::record_candidate_bytes(report.metrics.source_bytes as u64); crate::hotpath_observe::add_parse_bytes(report.metrics.source_bytes as u64); if matches!(report.reuse, ParseReuse::Noop | ParseReuse::Incremental) { crate::hotpath_observe::add_reused_parses(1); diff --git a/src/daemon/code_index_scheduler/git_tree_capture.rs b/src/daemon/code_index_scheduler/git_tree_capture.rs index 8918ae6545..43b967505a 100644 --- a/src/daemon/code_index_scheduler/git_tree_capture.rs +++ b/src/daemon/code_index_scheduler/git_tree_capture.rs @@ -3,6 +3,8 @@ use std::collections::BTreeSet; use std::path::Path; use std::sync::Arc; +#[cfg(feature = "hotpath")] +use std::sync::atomic::AtomicU64; use gix::bstr::ByteSlice; use tracedecay_code_index::production::CodeIndexExecutionControlV1; @@ -52,6 +54,116 @@ pub(super) fn classify_capture_failure( /// log flood. const MAX_REPORTED_WITHHELD_SOURCES: usize = 16; +/// Publish capture progress at a coarse cadence so a large tree does not +/// turn one file into one profiler event. The final values are always flushed +/// by `Drop`, including cancellation and other early-return paths. +#[cfg(feature = "hotpath")] +const CAPTURE_PROGRESS_UPDATE_PERIOD: u64 = 32; + +pub(super) struct CaptureProgressV1 { + #[cfg(feature = "hotpath")] + candidate_files: AtomicU64, + #[cfg(feature = "hotpath")] + candidate_bytes: AtomicU64, + #[cfg(feature = "hotpath")] + processed_files: AtomicU64, + #[cfg(feature = "hotpath")] + processed_bytes: AtomicU64, + #[cfg(feature = "hotpath")] + captured_files: AtomicU64, + #[cfg(feature = "hotpath")] + captured_bytes: AtomicU64, +} + +impl CaptureProgressV1 { + pub(super) const fn new() -> Self { + Self { + #[cfg(feature = "hotpath")] + candidate_files: AtomicU64::new(0), + #[cfg(feature = "hotpath")] + candidate_bytes: AtomicU64::new(0), + #[cfg(feature = "hotpath")] + processed_files: AtomicU64::new(0), + #[cfg(feature = "hotpath")] + processed_bytes: AtomicU64::new(0), + #[cfg(feature = "hotpath")] + captured_files: AtomicU64::new(0), + #[cfg(feature = "hotpath")] + captured_bytes: AtomicU64::new(0), + } + } + + #[inline(always)] + pub(super) fn observe_candidate(&self, bytes: usize) { + #[cfg(feature = "hotpath")] + { + self.candidate_files.fetch_add(1, Ordering::Relaxed); + self.candidate_bytes + .fetch_add(bytes as u64, Ordering::Relaxed); + self.publish_at_cadence(false); + } + #[cfg(not(feature = "hotpath"))] + let _ = bytes; + } + + #[inline(always)] + pub(super) fn observe_processed(&self, bytes: usize) { + #[cfg(feature = "hotpath")] + { + self.processed_files.fetch_add(1, Ordering::Relaxed); + self.processed_bytes + .fetch_add(bytes as u64, Ordering::Relaxed); + self.publish_at_cadence(false); + } + #[cfg(not(feature = "hotpath"))] + let _ = bytes; + } + + #[inline(always)] + pub(super) fn observe_captured(&self, bytes: usize) { + #[cfg(feature = "hotpath")] + { + self.captured_files.fetch_add(1, Ordering::Relaxed); + self.captured_bytes + .fetch_add(bytes as u64, Ordering::Relaxed); + self.publish_at_cadence(false); + } + #[cfg(not(feature = "hotpath"))] + let _ = bytes; + } + + #[cfg(feature = "hotpath")] + #[inline(always)] + fn publish_at_cadence(&self, force: bool) { + let candidate_files = self.candidate_files.load(Ordering::Relaxed); + let processed_files = self.processed_files.load(Ordering::Relaxed); + let captured_files = self.captured_files.load(Ordering::Relaxed); + if !force + && !candidate_files.is_multiple_of(CAPTURE_PROGRESS_UPDATE_PERIOD) + && !processed_files.is_multiple_of(CAPTURE_PROGRESS_UPDATE_PERIOD) + && !captured_files.is_multiple_of(CAPTURE_PROGRESS_UPDATE_PERIOD) + { + return; + } + hotpath::gauge!("code_index.capture.candidate_files").set(candidate_files); + hotpath::gauge!("code_index.capture.candidate_bytes") + .set(self.candidate_bytes.load(Ordering::Relaxed)); + hotpath::gauge!("code_index.capture.processed_files").set(processed_files); + hotpath::gauge!("code_index.capture.processed_bytes") + .set(self.processed_bytes.load(Ordering::Relaxed)); + hotpath::gauge!("code_index.capture.captured_files").set(captured_files); + hotpath::gauge!("code_index.capture.captured_bytes") + .set(self.captured_bytes.load(Ordering::Relaxed)); + } +} + +impl Drop for CaptureProgressV1 { + fn drop(&mut self) { + #[cfg(feature = "hotpath")] + self.publish_at_cadence(true); + } +} + pub(super) fn report_withheld_sources(withheld: &[WithheldSourceV1]) { if withheld.is_empty() { return; @@ -165,49 +277,73 @@ impl CodeIndexWorktreeSchedulerV1 { registry: &StaticLanguageRegistry, logical_path: &str, raw_bytes: &[u8], + ) -> Result, CodeIndexSchedulerErrorV1> { + self.capture_candidate_bytes_with_progress(registry, logical_path, raw_bytes, None) + } + + pub(super) fn capture_candidate_bytes_with_progress( + &self, + registry: &StaticLanguageRegistry, + logical_path: &str, + raw_bytes: &[u8], + progress: Option<&CaptureProgressV1>, ) -> Result, CodeIndexSchedulerErrorV1> { if self.shutting_down.load(Ordering::Acquire) { return Err(cancelled_code_index_reconcile()); } - let Some(extension) = Path::new(logical_path) - .extension() - .and_then(|value| value.to_str()) - else { - return Ok(None); - }; - let Some(descriptor) = registry.descriptor_for_extension(&extension.to_lowercase()) else { - return Ok(None); - }; - let (sanitized_bytes, sensitivity_level, receipt_id) = - privacy::sanitize_code_file(&descriptor.language, raw_bytes)?; - let (digest, shared) = self.byte_pool.intern(sanitized_bytes); - let retained_reservation = self.reserve_snapshot_memory(&digest, shared.len())?; - let occurrence = file_occurrence_id( - &self.repository_id, - &self.worktree_id, - logical_path, - &digest, - &receipt_id, - )?; - Ok(Some(CapturedCandidateV1 { - file: SanitizedCodeFileV1 { - file_occurrence_id: occurrence.clone(), - logical_path: logical_path.to_owned(), - language: Some(descriptor.language.clone()), - content_digest: digest, - disposition: SnapshotFileDispositionV1::Present, - }, - captured: CodeIndexCapturedFileV1 { - file_occurrence_id: occurrence, - sanitized_bytes: Arc::clone(&shared), - sensitivity_level, - }, - receipt_id, - retained: shared, - retained_reservation, - })) + if let Some(progress) = progress { + progress.observe_candidate(raw_bytes.len()); + } + let result: Result, CodeIndexSchedulerErrorV1> = (|| { + let Some(extension) = Path::new(logical_path) + .extension() + .and_then(|value| value.to_str()) + else { + return Ok(None); + }; + let Some(descriptor) = registry.descriptor_for_extension(&extension.to_lowercase()) + else { + return Ok(None); + }; + let (sanitized_bytes, sensitivity_level, receipt_id) = + privacy::sanitize_code_file(&descriptor.language, raw_bytes)?; + let (digest, shared) = self.byte_pool.intern(sanitized_bytes); + let retained_reservation = self.reserve_snapshot_memory(&digest, shared.len())?; + let occurrence = file_occurrence_id( + &self.repository_id, + &self.worktree_id, + logical_path, + &digest, + &receipt_id, + )?; + Ok(Some(CapturedCandidateV1 { + file: SanitizedCodeFileV1 { + file_occurrence_id: occurrence.clone(), + logical_path: logical_path.to_owned(), + language: Some(descriptor.language.clone()), + content_digest: digest, + disposition: SnapshotFileDispositionV1::Present, + }, + captured: CodeIndexCapturedFileV1 { + file_occurrence_id: occurrence, + sanitized_bytes: Arc::clone(&shared), + sensitivity_level, + }, + receipt_id, + retained: shared, + retained_reservation, + })) + })(); + if let Some(progress) = progress { + progress.observe_processed(raw_bytes.len()); + if let Ok(Some(candidate)) = result.as_ref() { + progress.observe_captured(candidate.captured.sanitized_bytes.len()); + } + } + result } + #[hotpath::measure(label = "code_index.capture.exact_git_tree")] pub(super) fn capture_exact_git_tree_snapshot( &self, source: &ExactGitTreeSourceV1, @@ -254,6 +390,7 @@ impl CodeIndexWorktreeSchedulerV1 { entries.sort_by(|left, right| left.filepath.cmp(&right.filepath)); let registry = StaticLanguageRegistry::new(); + let progress = CaptureProgressV1::new(); let mut files = Vec::new(); let mut captured_files = Vec::new(); let mut sanitization_receipts = BTreeSet::new(); @@ -273,8 +410,12 @@ impl CodeIndexWorktreeSchedulerV1 { let blob = repository .find_blob(entry.oid) .map_err(|_| CodeIndexSearchUnavailableReasonV1::GenerationUnavailable)?; - let candidate = match self.capture_candidate_bytes(®istry, &logical_path, &blob.data) - { + let candidate = match self.capture_candidate_bytes_with_progress( + ®istry, + &logical_path, + &blob.data, + Some(&progress), + ) { Ok(Some(candidate)) => candidate, Ok(None) => continue, Err(error) => { From f9ce68020beaf1dcbfe9b7d61dc76e80a0b10b66 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 05:04:06 +0000 Subject: [PATCH 36/69] fix(index): bind text catch-up to active seal --- src/daemon/code_index_scheduler.rs | 31 ++++++- src/daemon/code_index_scheduler/registry.rs | 15 ++++ src/daemon/code_index_scheduler/tests.rs | 90 +++++++++++++-------- 3 files changed, 98 insertions(+), 38 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 5a8cf1965a..f09fbfb205 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -1788,7 +1788,24 @@ pub(in crate::daemon) struct LatestCodeTextGenerationV1 { /// that same reader to the artifact build. The full generation is never /// decoded merely to discover text metadata or source layout. preopened_source: Arc>>>, - publication_binding: Option>, + publication_binding: Option>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct DurableActiveSealedGenerationBindingV1 { + generation_id: CodeGenerationId, + generation_file: String, + state_digest: ManifestDigest, +} + +impl DurableActiveSealedGenerationBindingV1 { + fn matches(&self, pointer: Option<&DurablePublicationPointerV1>) -> bool { + pointer.is_some_and(|pointer| { + pointer.generation_id == self.generation_id.as_str() + && pointer.generation_file == self.generation_file + && pointer.state_digest == self.state_digest.as_str() + }) + } } impl std::ops::Deref for LatestCompleteCodeIndexV1 { @@ -2411,6 +2428,10 @@ impl LatestCodeTextGenerationV1 { !self.text_serving_is_ready() && !self.text_projection_failed.load(Ordering::Acquire) } + fn same_text_owner(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.text_projection_build, &other.text_projection_build) + } + fn mark_text_serving_failed(&self) { self.text_projection_failed.store(true, Ordering::Release); } @@ -2848,7 +2869,7 @@ impl LatestCodeTextGenerationV1 { .publication .read_publication_pointer() .map_err(text_artifact_unavailable)?; - if current.as_ref() != Some(binding.as_ref()) { + if !binding.matches(current.as_ref()) { self.text_control.retire(); return Err(RetrievalPortError::Cancelled); } @@ -4091,7 +4112,11 @@ impl CodeIndexWorktreeSchedulerV1 { text_progress_producer_incarnation: self.progress_producer_incarnation, text_artifact_store, preopened_source: Arc::new(Mutex::new(Some(source))), - publication_binding: Some(Arc::new(pointer)), + publication_binding: Some(Arc::new(DurableActiveSealedGenerationBindingV1 { + generation_id, + generation_file: pointer.generation_file, + state_digest: ManifestDigest::new(pointer.state_digest).ok()?, + })), }) } diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 6753cbdf61..65c3848da7 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -2426,6 +2426,21 @@ impl CodeIndexSchedulerRegistryV1 { text_slice_incomplete = true; } Ok(Err(error)) => { + if matches!( + &error, + tracedecay_query::retrieval::RetrievalPortError::Cancelled + ) + { + let mut current = worker_text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if current + .as_ref() + .is_some_and(|current| current.same_text_owner(&failed_latest)) + { + *current = None; + } + } tracing::warn!( event = "code_index_text_projection_failed", error = %error, diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index b95c573a02..422667ec7a 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -2565,8 +2565,27 @@ fn retained_text_generation_reaches_query_owners_without_full_sealed_decode() { let text = reopened .servable_retained_text_generation() .expect("active durable text generation"); + let binding = text + .publication_binding + .clone() + .expect("retained generation binding"); + let mut retained_history_update = reopened + .publication + .read_publication_pointer() + .expect("read active pointer") + .expect("active pointer"); + retained_history_update.generation_index.clear(); + retained_history_update.generation_index_truncated = true; + retained_history_update.generation_index_digest = None; + assert!( + binding.matches(Some(&retained_history_update)), + "retention-history changes must not supersede the same active seal" + ); let scan = build_progress_snapshot(&reopened); - assert_eq!(scan.phase, CodeIndexBuildPhaseV1::SourceScan); + assert_eq!( + scan.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::SourceScan + ); assert!(scan.total_lexical_bytes > 0); assert_eq!(scan.completed_lexical_bytes, scan.total_lexical_bytes); assert_eq!( @@ -2584,6 +2603,11 @@ fn retained_text_generation_reaches_query_owners_without_full_sealed_decode() { 0, "exact and lexical owners must not require the full generation" ); + assert!( + text.advance_text_serving(1) + .expect("attached text descriptor preserves the active seal"), + "a text-head pointer update must not retire its own ready owner" + ); fixture.edit( "src/lib.rs", @@ -9375,11 +9399,11 @@ async fn witness_verified_mount_activates_without_rebuild() { registry.shutdown().await; } -/// A retained generation is not serving until its persistent graph replay -/// activates. A typed replay failure leaves the slot empty while the ordinary -/// refresh remains pending. +/// A retained text generation remains serving when persistent graph replay +/// fails. The full graph owner stays absent while ordinary refresh remains +/// pending, so exact/lexical availability never implies graph availability. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn failed_cold_mount_graph_replay_never_seats_retained_generation() { +async fn failed_cold_mount_graph_replay_preserves_retained_text_generation() { let fixture = GitFixture::new(ALPHA_LIB_V1); let store = TempDir::new().expect("store root"); let bytes = Arc::new(SharedCodeIndexBytePoolV1::default()); @@ -9387,18 +9411,21 @@ async fn failed_cold_mount_graph_replay_never_seats_retained_generation() { store.path(), &fixture.path().canonicalize().expect("canonical fixture"), ); - let scope = { + let (scope, seeded_generation_id) = { let mut scheduler = scheduler(&fixture, scoped_store, bytes); published(scheduler.reconcile_now().expect("seed generation")); let latest = scheduler.latest_complete().expect("seeded generation"); let snapshot = latest.generation.snapshot(); - ResolvedScope::new( - test_project_id(), - snapshot.repository.clone(), - snapshot.worktree.clone().expect("worktree id"), - snapshot.reference.clone(), + ( + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + snapshot.worktree.clone().expect("worktree id"), + snapshot.reference.clone(), + ) + .expect("resolved scope"), + latest.generation.manifest().generation_id.clone(), ) - .expect("resolved scope") }; let profile = TempDir::new().expect("profile root"); @@ -9463,31 +9490,22 @@ async fn failed_cold_mount_graph_replay_never_seats_retained_generation() { .scheduler, ) }; - let (held_tx, held_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); - let lock_thread = std::thread::spawn(move || { - let _guard = scheduler - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - held_tx.send(()).expect("signal scheduler held"); - let _ = release_rx.recv(); - }); - held_rx.recv().expect("scheduler lock acquired"); drop(admission); let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { - if registry.pending_wake_micros_for_scope(&scope).await == Some(0) { + if scheduler + .try_lock() + .is_ok_and(|scheduler| scheduler.sealed_decode_count() > 0) + { break; } assert!( std::time::Instant::now() <= deadline, - "worker did not dequeue the retained graph replay" + "worker did not decode the retained generation for graph replay" ); tokio::time::sleep(Duration::from_millis(10)).await; } - release_tx.send(()).expect("release scheduler"); - lock_thread.join().expect("join scheduler holder"); let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { @@ -9507,8 +9525,15 @@ async fn failed_cold_mount_graph_replay_never_seats_retained_generation() { assert_eq!( registry.latest_generation_id(fixture.path()).await, - None, - "a retained generation cannot serve after persistent graph replay fails" + Some(seeded_generation_id), + "persistent graph replay failure must not withhold retained text serving" + ); + assert!( + registry + .latest_complete_serving_for_scope(&scope) + .await + .is_none(), + "persistent graph replay failure must not expose a full graph owner" ); registry.shutdown().await; graph_runtime @@ -10053,14 +10078,9 @@ async fn retryable_activation_failure_retries_the_sealed_generation_without_rese let (scope, sealed_worktree_id, sealed_generation_id) = { let mut scheduler = scheduler(&fixture, scoped_store.clone(), bytes); published(scheduler.reconcile_now().expect("seed generation")); - let latest = scheduler - .latest_complete() - .expect("seeded generation"); + let latest = scheduler.latest_complete().expect("seeded generation"); let snapshot = latest.generation.snapshot(); - let worktree_id = snapshot - .worktree - .clone() - .expect("seeded worktree id"); + let worktree_id = snapshot.worktree.clone().expect("seeded worktree id"); ( ResolvedScope::new( test_project_id(), From 73dc94b3fc0d1bea16df942594174416e37d0f44 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 05:05:24 +0000 Subject: [PATCH 37/69] perf(hotpath): instrument authoritative snapshot capture --- src/daemon/code_index_scheduler.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index f09fbfb205..623a4a768b 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -4827,7 +4827,13 @@ impl CodeIndexWorktreeSchedulerV1 { .ignored_source_admissions .iter() .any(|admission| admission.logical_path == logical_path); - self.capture_admitted_candidate(registry, logical_path, control, explicitly_admitted) + self.capture_admitted_candidate( + registry, + logical_path, + control, + None, + explicitly_admitted, + ) } fn ignored_admission_paths(&self) -> BTreeSet<&str> { @@ -4842,6 +4848,7 @@ impl CodeIndexWorktreeSchedulerV1 { registry: &StaticLanguageRegistry, logical_path: &str, control: Option<&dyn CodeIndexExecutionControlV1>, + progress: Option<&git_tree_capture::CaptureProgressV1>, explicitly_admitted: bool, ) -> Result, CodeIndexSchedulerErrorV1> { if !explicitly_admitted && crate::config::is_generated_path_segment(logical_path) { @@ -4861,9 +4868,10 @@ impl CodeIndexWorktreeSchedulerV1 { ignored_dependencies::read_bounded_snapshot_source(&absolute, control)? }; ignored_dependencies::checkpoint_if_present(control)?; - self.capture_candidate_bytes(registry, logical_path, &raw_bytes) + self.capture_candidate_bytes_with_progress(registry, logical_path, &raw_bytes, progress) } + #[hotpath::measure(label = "code_index.capture.authoritative_snapshot")] fn capture_authoritative_snapshot( &self, control: Option<&dyn CodeIndexExecutionControlV1>, @@ -4955,6 +4963,7 @@ impl CodeIndexWorktreeSchedulerV1 { // so the captured snapshot is byte-identical to the sequential sweep. let candidates = candidate_paths.into_iter().collect::>(); let admitted_paths = self.ignored_admission_paths(); + let progress = git_tree_capture::CaptureProgressV1::new(); let outcomes = crate::code_index::parallelism::install(|| { use rayon::prelude::*; candidates @@ -4969,6 +4978,7 @@ impl CodeIndexWorktreeSchedulerV1 { ®istry, logical_path, control, + Some(&progress), admitted_paths.contains(logical_path.as_str()), ) }) From 72833e2cab870111c1a4bdd6c21f34726f4fad14 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 05:08:23 +0000 Subject: [PATCH 38/69] perf(hotpath): remove unused capture wrapper --- src/daemon/code_index_scheduler/git_tree_capture.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/daemon/code_index_scheduler/git_tree_capture.rs b/src/daemon/code_index_scheduler/git_tree_capture.rs index 43b967505a..2e15662055 100644 --- a/src/daemon/code_index_scheduler/git_tree_capture.rs +++ b/src/daemon/code_index_scheduler/git_tree_capture.rs @@ -272,15 +272,6 @@ impl DaemonCodeIndexPublicationStoreV1 { } impl CodeIndexWorktreeSchedulerV1 { - pub(super) fn capture_candidate_bytes( - &self, - registry: &StaticLanguageRegistry, - logical_path: &str, - raw_bytes: &[u8], - ) -> Result, CodeIndexSchedulerErrorV1> { - self.capture_candidate_bytes_with_progress(registry, logical_path, raw_bytes, None) - } - pub(super) fn capture_candidate_bytes_with_progress( &self, registry: &StaticLanguageRegistry, From 12ea2c61944360cccdbb4c9ac08554b155e42944 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 05:23:17 +0000 Subject: [PATCH 39/69] perf(hotpath): trace artifact publish and reconcile ttfq --- src/daemon/code_index_scheduler.rs | 39 ++++++++++++++------- src/daemon/code_index_scheduler/registry.rs | 11 ++++++ 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 623a4a768b..70c476359b 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -2240,8 +2240,12 @@ impl DaemonCodeTextArtifactStoreV1 { // a plan made before the descriptor was attached. let lock = acquire_code_generation_store_lock(&self.store_root) .map_err(text_artifact_unavailable)?; - let (artifact_hex, artifact_size_bytes) = - sha256_private_file_hex_and_size(staging_path, control)?; + let (artifact_hex, artifact_size_bytes) = hotpath::measure_block!( + "query.artifact.store.state_digest", + sha256_private_file_hex_and_size(staging_path, control) + )?; + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.store.digest_bytes").set(artifact_size_bytes); let descriptor = DurableCodeTextArtifactDescriptorV1 { generation_id: generation_id.clone(), artifact_file: format!("text-artifact-{artifact_hex}.bin"), @@ -2258,8 +2262,10 @@ impl DaemonCodeTextArtifactStoreV1 { // object contains the named bytes. Verify the stable destination // before withdrawing staging evidence; a symlink, non-regular // object, truncated file, or same-name collision fails closed. - let (existing_hex, existing_size_bytes) = - sha256_private_file_hex_and_size(&final_path, control)?; + let (existing_hex, existing_size_bytes) = hotpath::measure_block!( + "query.artifact.store.dedupe_compare", + sha256_private_file_hex_and_size(&final_path, control) + )?; if existing_size_bytes != artifact_size_bytes { return Err(RetrievalPortError::Contract( "existing code text artifact does not match its content address" @@ -2279,8 +2285,11 @@ impl DaemonCodeTextArtifactStoreV1 { } Err(error) => return Err(text_artifact_unavailable(error)), } - DaemonCodeIndexPublicationStoreV1::sync_directory(&artifacts_root) - .map_err(text_artifact_unavailable)?; + hotpath::measure_block!( + "query.artifact.store.seal_fsync", + DaemonCodeIndexPublicationStoreV1::sync_directory(&artifacts_root) + .map_err(text_artifact_unavailable) + )?; let pointer = self .publication .read_publication_pointer() @@ -2291,13 +2300,16 @@ impl DaemonCodeTextArtifactStoreV1 { .to_owned(), ) })?; - attach_verified_text_artifact_under_lock( - &lock, - &pointer, - sealed_identity, - descriptor.clone(), - ) - .map_err(text_artifact_unavailable)?; + hotpath::measure_block!( + "query.artifact.store.pointer_commit", + attach_verified_text_artifact_under_lock( + &lock, + &pointer, + sealed_identity, + descriptor.clone(), + ) + .map_err(text_artifact_unavailable) + )?; Ok(descriptor) }) } @@ -4121,6 +4133,7 @@ impl CodeIndexWorktreeSchedulerV1 { } /// Retained-owner activation entry point. Foreground reads never call this. + #[hotpath::measure(label = "code_index.reconcile.pass")] pub(super) fn activate_or_reconcile( &mut self, ) -> Result { diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 65c3848da7..d8cc1a6fc6 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -1875,6 +1875,17 @@ impl CodeIndexSchedulerRegistryV1 { cadence_outcome, overflow_reconciled, ); + // The cadence receipt is created only after the serving-generation + // swap, so this is the truthful end-to-end wake-to-queryable sample. + // An un-attributable follow-up pass remains absent rather than + // fabricating a zero-latency sample. + #[cfg(feature = "hotpath")] + if let Some(ttfq_micros) = receipt.event_to_ready_micros() { + hotpath::gauge!("code_index_reconcile_wake_to_queryable_micros") + .set(ttfq_micros as f64); + } else { + hotpath::gauge!("code_index_reconcile_wake_without_arrival_total").inc(1_u64); + } // A successful publication is the terminal outcome operators need to see // to know a rebuild window actually closed, so it is `info`, not `debug`: // the cadence receipt below is debug-level and was invisible in the From 595ef9fbf1af0460acff7633cf557b8d90f0b27e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 05:28:53 +0000 Subject: [PATCH 40/69] perf(hotpath): trace sealed generation publication --- src/daemon/code_index_scheduler.rs | 38 +++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 70c476359b..1a101e6e08 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -1167,6 +1167,7 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { .map(|generation| generation.as_ref().clone())) } + #[hotpath::measure(label = "code_index.generation.publish")] fn publish_atomically( &mut self, _scope: &CodeIndexGenerationScopeV1, @@ -1223,13 +1224,21 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { Err(error) => return Err(Self::unavailable(error)), } let mut temporary = TemporaryGenerationFileV1::new(temporary_path); - let generation_size = Self::write_sealed_durable(&temporary.path, &generation)?; + let generation_size = hotpath::measure_block!( + "code_index.generation.publish.seal_fsync", + Self::write_sealed_durable(&temporary.path, &generation) + )?; if generation_size > MAX_DURABLE_GENERATION_INDEX_BYTES_V1 { return Err(Self::unavailable( "sealed code generation exceeds the durable history byte bound", )); } - let state_digest = Self::state_digest_file(&temporary.path)?; + let state_digest = hotpath::measure_block!( + "code_index.generation.publish.state_digest", + Self::state_digest_file(&temporary.path) + )?; + #[cfg(feature = "hotpath")] + hotpath::gauge!("code_index.generation.publish.digest_bytes").set(generation_size); let generation_file = format!( "generation-{}.json", state_digest @@ -1239,7 +1248,11 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { let generation_path = self.generations_root.join(&generation_file); match generation_path.symlink_metadata() { Ok(_) => { - if !Self::files_equal(&generation_path, &temporary.path)? { + let equal = hotpath::measure_block!( + "code_index.generation.publish.dedupe_compare", + Self::files_equal(&generation_path, &temporary.path) + )?; + if !equal { return Err(Self::unavailable( "immutable code-generation path contains different bytes", )); @@ -1346,14 +1359,17 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { if temporary.exists() { std::fs::remove_file(&temporary).map_err(Self::unavailable)?; } - Self::write_durable(&temporary, &bytes)?; - std::fs::rename(&temporary, &self.active_path).map_err(Self::unavailable)?; - Self::sync_directory( - self.active_path - .parent() - .ok_or_else(|| Self::unavailable("active pointer has no parent directory"))?, - )?; - self.remember_publication_pointer(&pointer, &bytes); + hotpath::measure_block!("code_index.generation.publish.pointer_commit", { + Self::write_durable(&temporary, &bytes)?; + std::fs::rename(&temporary, &self.active_path).map_err(Self::unavailable)?; + Self::sync_directory( + self.active_path + .parent() + .ok_or_else(|| Self::unavailable("active pointer has no parent directory"))?, + )?; + self.remember_publication_pointer(&pointer, &bytes); + Ok::<(), CodeIndexPublicationStoreErrorV1>(()) + })?; let mut state = self.cache.lock_state()?; if state .active From 9750d8341c94aa0e08c4575bac51b5430e1bb991 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 05:38:09 +0000 Subject: [PATCH 41/69] perf(hotpath): sample code-index file spans --- crates/tracedecay-code-index/src/extract.rs | 134 ++--- .../src/hotpath_observe.rs | 42 +- crates/tracedecay-code-index/src/intake.rs | 57 +- .../src/production/mod.rs | 487 +++++++++--------- .../src/retained_parse.rs | 264 +++++----- 5 files changed, 519 insertions(+), 465 deletions(-) diff --git a/crates/tracedecay-code-index/src/extract.rs b/crates/tracedecay-code-index/src/extract.rs index aa789a01d7..94e3c83b30 100644 --- a/crates/tracedecay-code-index/src/extract.rs +++ b/crates/tracedecay-code-index/src/extract.rs @@ -199,7 +199,6 @@ impl TreeSitterExtractor { }) } - #[hotpath::measure(label = "code_index.extract.incremental")] pub(crate) fn extract_preparsed( &self, file: &ReceiptBoundCodeFileV1, @@ -208,30 +207,32 @@ impl TreeSitterExtractor { parsed_len: usize, cancellation: &dyn ExtractionCancellation, ) -> Result { - if cancellation.is_cancelled() { - return Err(ExtractionFailureV1::Cancelled); - } - let authority = file.authority().clone(); - let file = file.validated_file(); - validate_descriptor(file, descriptor)?; - let admitted_prefix = file - .sanitized_bytes - .get(..parsed_len) - .and_then(|bytes| std::str::from_utf8(bytes).ok()); - if admitted_prefix.is_none() { - return Err(ExtractionFailureV1::ParseFailed { - detail: "retained parse prefix is not an admitted UTF-8 boundary".to_owned(), - }); - } - finish_extraction( - authority, - file, - descriptor, - artifact, - parsed_len, - parsed_len < file.sanitized_bytes.len(), - cancellation, - ) + crate::hotpath_observe::measure_hot_loop!("code_index.extract.incremental", { + if cancellation.is_cancelled() { + return Err(ExtractionFailureV1::Cancelled); + } + let authority = file.authority().clone(); + let file = file.validated_file(); + validate_descriptor(file, descriptor)?; + let admitted_prefix = file + .sanitized_bytes + .get(..parsed_len) + .and_then(|bytes| std::str::from_utf8(bytes).ok()); + if admitted_prefix.is_none() { + return Err(ExtractionFailureV1::ParseFailed { + detail: "retained parse prefix is not an admitted UTF-8 boundary".to_owned(), + }); + } + finish_extraction( + authority, + file, + descriptor, + artifact, + parsed_len, + parsed_len < file.sanitized_bytes.len(), + cancellation, + ) + }) } /// Typed evidence for one file whose bounded retained parse exceeded its @@ -497,55 +498,56 @@ pub(crate) fn parser_import_rows_digest( } impl LanguageExtractor for TreeSitterExtractor { - #[hotpath::measure(label = "code_index.extract.full")] fn extract( &self, file: &ReceiptBoundCodeFileV1, descriptor: &LanguageDescriptorV1, cancellation: &dyn ExtractionCancellation, ) -> Result { - if cancellation.is_cancelled() { - return Err(ExtractionFailureV1::Cancelled); - } - let authority = file.authority().clone(); - let file = file.validated_file(); - validate_descriptor(file, descriptor)?; - - let parser = self.resolve_parser(file, descriptor).ok_or({ - ExtractionFailureV1::GrammarUnavailable { - language: descriptor.language.clone(), + crate::hotpath_observe::measure_hot_loop!("code_index.extract.full", { + if cancellation.is_cancelled() { + return Err(ExtractionFailureV1::Cancelled); } - })?; - if canonical_language_id(parser.language_name()) != descriptor.language.as_str() { - return Err(ExtractionFailureV1::IncompatibleDescriptor { - detail: format!( - "descriptor {} resolved to a {} parser", - descriptor.language, - parser.language_name() - ), - }); - } - - let source = std::str::from_utf8(&file.sanitized_bytes).map_err(|error| { - ExtractionFailureV1::ParseFailed { - detail: format!("sanitized bytes are not valid UTF-8: {error}"), + let authority = file.authority().clone(); + let file = file.validated_file(); + validate_descriptor(file, descriptor)?; + + let parser = self.resolve_parser(file, descriptor).ok_or({ + ExtractionFailureV1::GrammarUnavailable { + language: descriptor.language.clone(), + } + })?; + if canonical_language_id(parser.language_name()) != descriptor.language.as_str() { + return Err(ExtractionFailureV1::IncompatibleDescriptor { + detail: format!( + "descriptor {} resolved to a {} parser", + descriptor.language, + parser.language_name() + ), + }); } - })?; - let parsed_len = - crate::chunks::snap_down(source, source.len().min(MAX_EXTRACTION_SOURCE_BYTES)); - let extraction_source = &source[..parsed_len]; - let source_was_capped = parsed_len < source.len(); - - let artifact = parser.extract_artifact(&file.file.logical_path, extraction_source); - finish_extraction( - authority, - file, - descriptor, - artifact, - parsed_len, - source_was_capped, - cancellation, - ) + + let source = std::str::from_utf8(&file.sanitized_bytes).map_err(|error| { + ExtractionFailureV1::ParseFailed { + detail: format!("sanitized bytes are not valid UTF-8: {error}"), + } + })?; + let parsed_len = + crate::chunks::snap_down(source, source.len().min(MAX_EXTRACTION_SOURCE_BYTES)); + let extraction_source = &source[..parsed_len]; + let source_was_capped = parsed_len < source.len(); + + let artifact = parser.extract_artifact(&file.file.logical_path, extraction_source); + finish_extraction( + authority, + file, + descriptor, + artifact, + parsed_len, + source_was_capped, + cancellation, + ) + }) } } diff --git a/crates/tracedecay-code-index/src/hotpath_observe.rs b/crates/tracedecay-code-index/src/hotpath_observe.rs index ad8757f2e2..20a3ef2c66 100644 --- a/crates/tracedecay-code-index/src/hotpath_observe.rs +++ b/crates/tracedecay-code-index/src/hotpath_observe.rs @@ -17,7 +17,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; #[cfg(feature = "hotpath")] use std::time::Instant; -#[cfg(feature = "hotpath")] +#[cfg(any(feature = "hotpath", test))] const HOT_LOOP_SAMPLE_PERIOD: u64 = 32; #[cfg(feature = "hotpath")] @@ -27,16 +27,21 @@ static WORKERS_ACTIVE: AtomicUsize = AtomicUsize::new(0); #[cfg(feature = "hotpath")] static WORKERS_POOL_COORDINATION: AtomicUsize = AtomicUsize::new(0); #[cfg(feature = "hotpath")] -static GREP_FILE_SAMPLE: AtomicU64 = AtomicU64::new(0); +static HOT_LOOP_SAMPLE: AtomicU64 = AtomicU64::new(0); + +#[cfg(any(feature = "hotpath", test))] +#[must_use] +#[inline(always)] +fn is_hot_loop_sample(sequence: u64) -> bool { + sequence.is_multiple_of(HOT_LOOP_SAMPLE_PERIOD) +} #[must_use] #[inline(always)] pub(crate) fn sample_hot_loop() -> bool { #[cfg(feature = "hotpath")] { - GREP_FILE_SAMPLE - .fetch_add(1, Ordering::Relaxed) - .is_multiple_of(HOT_LOOP_SAMPLE_PERIOD) + is_hot_loop_sample(HOT_LOOP_SAMPLE.fetch_add(1, Ordering::Relaxed)) } #[cfg(not(feature = "hotpath"))] { @@ -44,6 +49,24 @@ pub(crate) fn sample_hot_loop() -> bool { } } +/// Measure one fixed-rate sample from a hot file loop. +/// +/// The label must be a literal so callers cannot create path- or +/// generation-shaped cardinality. The disabled path still evaluates only the +/// work expression; its sampler is an inlined constant `false` with no atomic +/// or clock access. +macro_rules! measure_hot_loop { + ($label:literal, $work:expr) => {{ + if $crate::hotpath_observe::sample_hot_loop() { + hotpath::measure_block!($label, $work) + } else { + $work + } + }}; +} + +pub(crate) use measure_hot_loop; + #[cfg(any(feature = "hotpath", test))] #[inline(always)] fn decrement_if_positive(counter: &AtomicUsize) -> bool { @@ -347,6 +370,15 @@ pub(crate) fn record_rebuild_state(state: &'static str) { mod tests { use super::*; + #[test] + fn hot_loop_sampling_uses_one_fixed_slot_per_period() { + let sampled = (0..(HOT_LOOP_SAMPLE_PERIOD * 2)) + .filter(|sequence| is_hot_loop_sample(*sequence)) + .collect::>(); + + assert_eq!(sampled, vec![0, HOT_LOOP_SAMPLE_PERIOD]); + } + #[test] fn pending_queue_decrements_when_each_worker_starts() { let queue = PendingWorkQueue::new(3); diff --git a/crates/tracedecay-code-index/src/intake.rs b/crates/tracedecay-code-index/src/intake.rs index 8ee22ecfbe..502bd84c6c 100644 --- a/crates/tracedecay-code-index/src/intake.rs +++ b/crates/tracedecay-code-index/src/intake.rs @@ -270,40 +270,41 @@ impl CodeIndexIntake for SanitizedCodeIntake { ) } - #[hotpath::measure(label = "code_index.intake.bind_file")] fn bind_file( &self, capability: &SanitizedSnapshotCapabilityV1, project_id: &ProjectId, file: ValidatedCodeFileV1, ) -> Result { - if project_id.validate().is_err() { - return Err(IntakeRejectionV1::UnsanitizedInput); - } - if file.snapshot_digest != capability.snapshot.intake_digest - || file.file.disposition != SnapshotFileDispositionV1::Present - || content_digest(&file.sanitized_bytes) != file.file.content_digest - || std::str::from_utf8(&file.sanitized_bytes).is_err() - { - return Err(IntakeRejectionV1::UnsanitizedInput); - } - let admitted_file = capability - .files_by_occurrence - .get(&file.file.file_occurrence_id) - .and_then(|index| capability.snapshot.snapshot.files.get(*index)); - if admitted_file != Some(&file.file) { - return Err(IntakeRejectionV1::UnsanitizedInput); - } - let snapshot = &capability.snapshot.snapshot; - let authority = ReceiptBoundCodeFileAuthorityV1 { - project_id: project_id.clone(), - repository_id: snapshot.repository.clone(), - worktree_id: snapshot.worktree.clone(), - reference: snapshot.reference.clone(), - logical_path: file.file.logical_path.clone(), - content_digest: file.file.content_digest.clone(), - }; - Ok(ReceiptBoundCodeFileV1 { file, authority }) + crate::hotpath_observe::measure_hot_loop!("code_index.intake.bind_file", { + if project_id.validate().is_err() { + return Err(IntakeRejectionV1::UnsanitizedInput); + } + if file.snapshot_digest != capability.snapshot.intake_digest + || file.file.disposition != SnapshotFileDispositionV1::Present + || content_digest(&file.sanitized_bytes) != file.file.content_digest + || std::str::from_utf8(&file.sanitized_bytes).is_err() + { + return Err(IntakeRejectionV1::UnsanitizedInput); + } + let admitted_file = capability + .files_by_occurrence + .get(&file.file.file_occurrence_id) + .and_then(|index| capability.snapshot.snapshot.files.get(*index)); + if admitted_file != Some(&file.file) { + return Err(IntakeRejectionV1::UnsanitizedInput); + } + let snapshot = &capability.snapshot.snapshot; + let authority = ReceiptBoundCodeFileAuthorityV1 { + project_id: project_id.clone(), + repository_id: snapshot.repository.clone(), + worktree_id: snapshot.worktree.clone(), + reference: snapshot.reference.clone(), + logical_path: file.file.logical_path.clone(), + content_digest: file.file.content_digest.clone(), + }; + Ok(ReceiptBoundCodeFileV1 { file, authority }) + }) } } diff --git a/crates/tracedecay-code-index/src/production/mod.rs b/crates/tracedecay-code-index/src/production/mod.rs index 8b4965280c..868460826b 100644 --- a/crates/tracedecay-code-index/src/production/mod.rs +++ b/crates/tracedecay-code-index/src/production/mod.rs @@ -299,7 +299,6 @@ const MAX_PHYSICAL_CODE_ARTIFACTS: usize = 1_024; /// it unwind out of the pool instead aborted the whole fan-out and surfaced in /// the daemon only as an opaque `JoinError`, so a single malformed file took /// down every other file's work in the same generation. -#[hotpath::measure] fn collect_bounded_ordered(items: &[T], operation: F) -> Result, E> where T: Sync, @@ -367,7 +366,6 @@ pub struct SharedPhysicalCodeArtifactPoolV1 { state: Arc>, } -#[hotpath::measure] fn upgrade_weak_under_lock( state: &Mutex, select: impl FnOnce(&S) -> Option>, @@ -379,51 +377,53 @@ fn upgrade_weak_under_lock( } impl SharedPhysicalCodeArtifactPoolV1 { - #[hotpath::measure] fn reuse( &self, key: &ManifestDigest, file: &ReceiptBoundCodeFileV1, worker: &crate::hotpath_observe::WorkerBusyGuard, ) -> Option> { - let artifact = { - let _coordination = worker.pool_coordination(); - upgrade_weak_under_lock(&self.state, |state| state.artifacts.get(key).cloned()) - }?; - let rebound = Arc::new(artifact.rematerialize_for_file(file).ok()?); - { - let _coordination = worker.pool_coordination(); - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - state.reused = state.reused.saturating_add(1); - } - Some(rebound) + crate::hotpath_observe::measure_hot_loop!("code_index.artifact_pool.reuse", { + let artifact = { + let _coordination = worker.pool_coordination(); + upgrade_weak_under_lock(&self.state, |state| state.artifacts.get(key).cloned()) + }?; + let rebound = Arc::new(artifact.rematerialize_for_file(file).ok()?); + { + let _coordination = worker.pool_coordination(); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.reused = state.reused.saturating_add(1); + } + Some(rebound) + }) } /// Record one generation-owned artifact under its physical reuse key. /// The pool retains only a weak index entry, so indexing a cold generation /// never deep-clones or pins the parsed/chunked payload. - #[hotpath::measure] fn insert(&self, key: ManifestDigest, artifact: &Arc) { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(retained) = state.artifacts.get_mut(&key) { - *retained = Arc::downgrade(artifact); - return; - } - while state.artifacts.len() >= MAX_PHYSICAL_CODE_ARTIFACTS { - let Some(evicted) = state.insertion_order.pop_front() else { - break; - }; - state.artifacts.remove(&evicted); - } - state.insertion_order.push_back(key.clone()); - state.artifacts.insert(key, Arc::downgrade(artifact)); - state.inserted = state.inserted.saturating_add(1); + crate::hotpath_observe::measure_hot_loop!("code_index.artifact_pool.insert", { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(retained) = state.artifacts.get_mut(&key) { + *retained = Arc::downgrade(artifact); + return; + } + while state.artifacts.len() >= MAX_PHYSICAL_CODE_ARTIFACTS { + let Some(evicted) = state.insertion_order.pop_front() else { + break; + }; + state.artifacts.remove(&evicted); + } + state.insertion_order.push_back(key.clone()); + state.artifacts.insert(key, Arc::downgrade(artifact)); + state.inserted = state.inserted.saturating_add(1); + }) } pub fn stats(&self) -> PhysicalCodeArtifactPoolStatsV1 { @@ -447,26 +447,27 @@ impl SharedPhysicalCodeArtifactPoolV1 { } impl FileGenerationArtifactsV1 { - #[hotpath::measure] fn rematerialize_for_file( &self, file: &ReceiptBoundCodeFileV1, ) -> Result { - let target = file.validated_file(); - let artifacts = self.artifacts.rematerialize_for_generation( - target.generation_id.clone(), - target.file.file_occurrence_id.clone(), - )?; - let exact_authority = self - .exact_authority - .rematerialize_for_generation(&self.artifacts.chunks, &artifacts.chunks)?; - let extraction = rebind_extraction_batch(&self.authority, &self.extraction, file) - .map_err(|_| ChunkingFailureV1::GenerationMismatch)?; - Ok(Self { - authority: file.authority().clone(), - extraction, - artifacts, - exact_authority, + crate::hotpath_observe::measure_hot_loop!("code_index.artifact_pool.rematerialize", { + let target = file.validated_file(); + let artifacts = self.artifacts.rematerialize_for_generation( + target.generation_id.clone(), + target.file.file_occurrence_id.clone(), + )?; + let exact_authority = self + .exact_authority + .rematerialize_for_generation(&self.artifacts.chunks, &artifacts.chunks)?; + let extraction = rebind_extraction_batch(&self.authority, &self.extraction, file) + .map_err(|_| ChunkingFailureV1::GenerationMismatch)?; + Ok(Self { + authority: file.authority().clone(), + extraction, + artifacts, + exact_authority, + }) }) } } @@ -1004,41 +1005,44 @@ impl CodeIndexPublishedGenerationV1 { .iter() .map(|candidate| (&candidate.file_occurrence_id, candidate)) .collect::>(); - collect_bounded_ordered(&files, |file, _worker| { - file.artifacts - .validate() - .map_err(CodeIndexProductionErrorV1::Chunk)?; - let occurrence = occurrences_by_id - .get(&file.artifacts.chunks.document.file_occurrence_id) - .copied(); - if file.authority.project_id != self.manifest.project_id { - return Err(CodeIndexProductionErrorV1::Contract( - "published file authority project does not match the generation manifest" - .to_owned(), - )); - } - if file.authority.repository_id != self.snapshot.repository - || file.authority.worktree_id != self.snapshot.worktree - || file.authority.reference != self.snapshot.reference - || occurrence.is_none_or(|occurrence| { - occurrence.logical_path != file.authority.logical_path - || occurrence.content_digest != file.authority.content_digest - }) - || file.extraction.content_digest != file.authority.content_digest - || file.extraction.generation_id != self.manifest.generation_id - || file.extraction.file_occurrence_id - != file.artifacts.chunks.document.file_occurrence_id - { - return Err(CodeIndexProductionErrorV1::Contract( + hotpath::measure_block!( + "code_index.collect.validate_files", + collect_bounded_ordered(&files, |file, _worker| { + file.artifacts + .validate() + .map_err(CodeIndexProductionErrorV1::Chunk)?; + let occurrence = occurrences_by_id + .get(&file.artifacts.chunks.document.file_occurrence_id) + .copied(); + if file.authority.project_id != self.manifest.project_id { + return Err(CodeIndexProductionErrorV1::Contract( + "published file authority project does not match the generation manifest" + .to_owned(), + )); + } + if file.authority.repository_id != self.snapshot.repository + || file.authority.worktree_id != self.snapshot.worktree + || file.authority.reference != self.snapshot.reference + || occurrence.is_none_or(|occurrence| { + occurrence.logical_path != file.authority.logical_path + || occurrence.content_digest != file.authority.content_digest + }) + || file.extraction.content_digest != file.authority.content_digest + || file.extraction.generation_id != self.manifest.generation_id + || file.extraction.file_occurrence_id + != file.artifacts.chunks.document.file_occurrence_id + { + return Err(CodeIndexProductionErrorV1::Contract( "extraction authority does not match its published project, repository, scope, path, or content" .to_owned(), )); - } - file.exact_authority - .validate_all(&file.artifacts.chunks.chunks) - .map_err(CodeIndexProductionErrorV1::Chunk)?; - Ok(()) - })?; + } + file.exact_authority + .validate_all(&file.artifacts.chunks.chunks) + .map_err(CodeIndexProductionErrorV1::Chunk)?; + Ok(()) + }) + )?; validate_import_evidence(&files, &self.imports)?; let mut chunks = files .iter() @@ -1514,7 +1518,6 @@ where /// snapshot order after the parallel sweep, and the key binds the same /// inputs either way, so recomputing it per recording was pure waste. #[allow(clippy::too_many_arguments)] - #[hotpath::measure] fn extract_file( config: &CodeIndexProductionConfigV1, physical_artifacts: &SharedPhysicalCodeArtifactPoolV1, @@ -1530,148 +1533,154 @@ where control: &dyn CodeIndexExecutionControlV1, worker: &crate::hotpath_observe::WorkerBusyGuard, ) -> Result<(ManifestDigest, Arc), CodeIndexProductionErrorV1> { - Self::checkpoint(control)?; - let captured = captured_files - .get(&file.file_occurrence_id) - .ok_or(CodeIndexInputErrorV1::MissingCapturedFile)?; - let receipt_bound = intake - .bind_file( - capability, - &config.project_id, - ValidatedCodeFileV1 { - generation_id: manifest.generation_id.clone(), - file: file.clone(), - snapshot_digest: capability.snapshot().intake_digest.clone(), - sanitized_bytes: captured.sanitized_bytes.to_vec(), - }, - ) - .map_err(CodeIndexProductionErrorV1::Intake)?; - let language = file.language.as_ref().ok_or_else(|| { - CodeIndexProductionErrorV1::Contract( - "present snapshot file has no declared language".to_owned(), - ) - })?; - let descriptor = intake.registry().descriptor(language).ok_or_else(|| { - CodeIndexProductionErrorV1::Contract( - "validated snapshot language has no descriptor".to_owned(), - ) - })?; - let physical_reuse_key = - Self::physical_reuse_key(config, file, descriptor, captured.sensitivity_level)?; - if let Some(reused) = physical_artifacts.reuse(&physical_reuse_key, &receipt_bound, worker) - { - crate::hotpath_observe::add_reused_parses(1); + crate::hotpath_observe::measure_hot_loop!("code_index.materialize.file", { Self::checkpoint(control)?; - return Ok((physical_reuse_key, reused)); - } - let snapshot = &capability.snapshot().snapshot; - let parser = extractor - .resolve_parser(receipt_bound.validated_file(), descriptor) - .ok_or_else(|| { - CodeIndexProductionErrorV1::Extraction(ExtractionFailureV1::GrammarUnavailable { - language: descriptor.language.clone(), - }) + let captured = captured_files + .get(&file.file_occurrence_id) + .ok_or(CodeIndexInputErrorV1::MissingCapturedFile)?; + let receipt_bound = intake + .bind_file( + capability, + &config.project_id, + ValidatedCodeFileV1 { + generation_id: manifest.generation_id.clone(), + file: file.clone(), + snapshot_digest: capability.snapshot().intake_digest.clone(), + sanitized_bytes: captured.sanitized_bytes.to_vec(), + }, + ) + .map_err(CodeIndexProductionErrorV1::Intake)?; + let language = file.language.as_ref().ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "present snapshot file has no declared language".to_owned(), + ) })?; - if crate::languages::canonical_language_id(parser.language_name()) - != descriptor.language.as_str() - { - return Err(CodeIndexProductionErrorV1::Extraction( - ExtractionFailureV1::IncompatibleDescriptor { - detail: format!( - "descriptor {} resolved to a {} parser", - descriptor.language, - parser.language_name() - ), - }, - )); - } - let cancellation = ExtractionControlBridge { control }; - let extraction = match parse_for_indexing( - retained_parses, - config, - snapshot, - repository_parse_identity, - file, - captured, - parser, - ) { - Ok((parse_artifacts, parsed_len)) => { + let descriptor = intake.registry().descriptor(language).ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "validated snapshot language has no descriptor".to_owned(), + ) + })?; + let physical_reuse_key = + Self::physical_reuse_key(config, file, descriptor, captured.sensitivity_level)?; + if let Some(reused) = + physical_artifacts.reuse(&physical_reuse_key, &receipt_bound, worker) + { + crate::hotpath_observe::add_reused_parses(1); Self::checkpoint(control)?; - extractor - .extract_preparsed( - &receipt_bound, - descriptor, - parse_artifacts, - parsed_len, - &cancellation, - ) - .map_err(|error| match error { - ExtractionFailureV1::Cancelled | ExtractionFailureV1::TimedOut => { - Self::interruption_error(control) - } - error => CodeIndexProductionErrorV1::Extraction(error), - })? + return Ok((physical_reuse_key, reused)); } - // One file exceeding the bounded parse budget is evidence about - // that file, never about the generation: record it as a typed - // unsupported document with a reason and keep building, instead - // of failing the whole reconcile cycle and leaving the served - // generation permanently stale. - Err(CodeIndexProductionErrorV1::RetainedParse(ParseError::TimedOut { .. })) => { - Self::checkpoint(control)?; - extractor - .extract_parse_timed_out(&receipt_bound, descriptor) - .map_err(CodeIndexProductionErrorV1::Extraction)? + let snapshot = &capability.snapshot().snapshot; + let parser = extractor + .resolve_parser(receipt_bound.validated_file(), descriptor) + .ok_or_else(|| { + CodeIndexProductionErrorV1::Extraction( + ExtractionFailureV1::GrammarUnavailable { + language: descriptor.language.clone(), + }, + ) + })?; + if crate::languages::canonical_language_id(parser.language_name()) + != descriptor.language.as_str() + { + return Err(CodeIndexProductionErrorV1::Extraction( + ExtractionFailureV1::IncompatibleDescriptor { + detail: format!( + "descriptor {} resolved to a {} parser", + descriptor.language, + parser.language_name() + ), + }, + )); } - Err(error) => return Err(error), - }; - Self::checkpoint(control)?; - let (artifacts, exact_authority) = chunker - .index_file_with_authority_from_extraction( - &receipt_bound, - &extraction, - descriptor, - captured.sensitivity_level, - &cancellation, - ) - .map_err(|error| match error { - ChunkingFailureV1::Cancelled => Self::interruption_error(control), - error => CodeIndexProductionErrorV1::Chunk(error), - })?; - Self::checkpoint(control)?; - let (authority, extraction, _) = extraction.into_parts(); - let artifact = Arc::new(FileGenerationArtifactsV1 { - authority, - extraction, - artifacts, - exact_authority, - }); - Ok((physical_reuse_key, artifact)) + let cancellation = ExtractionControlBridge { control }; + let extraction = match parse_for_indexing( + retained_parses, + config, + snapshot, + repository_parse_identity, + file, + captured, + parser, + ) { + Ok((parse_artifacts, parsed_len)) => { + Self::checkpoint(control)?; + extractor + .extract_preparsed( + &receipt_bound, + descriptor, + parse_artifacts, + parsed_len, + &cancellation, + ) + .map_err(|error| match error { + ExtractionFailureV1::Cancelled | ExtractionFailureV1::TimedOut => { + Self::interruption_error(control) + } + error => CodeIndexProductionErrorV1::Extraction(error), + })? + } + // One file exceeding the bounded parse budget is evidence about + // that file, never about the generation: record it as a typed + // unsupported document with a reason and keep building, instead + // of failing the whole reconcile cycle and leaving the served + // generation permanently stale. + Err(CodeIndexProductionErrorV1::RetainedParse(ParseError::TimedOut { .. })) => { + Self::checkpoint(control)?; + extractor + .extract_parse_timed_out(&receipt_bound, descriptor) + .map_err(CodeIndexProductionErrorV1::Extraction)? + } + Err(error) => return Err(error), + }; + Self::checkpoint(control)?; + let (artifacts, exact_authority) = chunker + .index_file_with_authority_from_extraction( + &receipt_bound, + &extraction, + descriptor, + captured.sensitivity_level, + &cancellation, + ) + .map_err(|error| match error { + ChunkingFailureV1::Cancelled => Self::interruption_error(control), + error => CodeIndexProductionErrorV1::Chunk(error), + })?; + Self::checkpoint(control)?; + let (authority, extraction, _) = extraction.into_parts(); + let artifact = Arc::new(FileGenerationArtifactsV1 { + authority, + extraction, + artifacts, + exact_authority, + }); + Ok((physical_reuse_key, artifact)) + }) } - #[hotpath::measure] fn physical_reuse_key( config: &CodeIndexProductionConfigV1, file: &SanitizedCodeFileV1, descriptor: &tracedecay_domain::LanguageDescriptorV1, sensitivity_level: SensitivityLevelV1, ) -> Result { - canonical_sha256(&( - PHYSICAL_CODE_ARTIFACT_REUSE_DIGEST_DOMAIN, - &config.project_id, - &config.repository, - &file.file_occurrence_id, - &file.logical_path, - &file.content_digest, - descriptor, - &config.sanitizer_revision, - &config.policy_revision, - &config.chunker_revision, - &config.privacy_domain, - config.privacy_key_epoch, - sensitivity_level, - )) - .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string())) + crate::hotpath_observe::measure_hot_loop!("code_index.materialize.reuse_key", { + canonical_sha256(&( + PHYSICAL_CODE_ARTIFACT_REUSE_DIGEST_DOMAIN, + &config.project_id, + &config.repository, + &file.file_occurrence_id, + &file.logical_path, + &file.content_digest, + descriptor, + &config.sanitizer_revision, + &config.policy_revision, + &config.chunker_revision, + &config.privacy_domain, + config.privacy_key_epoch, + sensitivity_level, + )) + .map_err(|error| CodeIndexProductionErrorV1::Contract(error.to_string())) + }) } #[allow(clippy::too_many_arguments)] @@ -1696,23 +1705,26 @@ where let config = &self.config; let physical_artifacts = &self.physical_artifacts; let retained_parses = &self.retained_parses; - let extracted = collect_bounded_ordered(&present_files, |file, worker| { - Self::extract_file( - config, - physical_artifacts, - retained_parses, - intake, - capability, - manifest, - extractor, - chunker, - repository_parse_identity, - file, - captured_files, - control, - worker, - ) - })?; + let extracted = hotpath::measure_block!( + "code_index.collect.materialize_full", + collect_bounded_ordered(&present_files, |file, worker| { + Self::extract_file( + config, + physical_artifacts, + retained_parses, + intake, + capability, + manifest, + extractor, + chunker, + repository_parse_identity, + file, + captured_files, + control, + worker, + ) + }) + )?; // Parallel completion order is intentionally not cache authority. // Record artifacts in canonical snapshot order so bounded eviction and // subsequent physical reuse remain deterministic. @@ -1761,11 +1773,13 @@ where let config = &self.config; let physical_artifacts = &self.physical_artifacts; let retained_parses = &self.retained_parses; - let file_materializations = collect_bounded_ordered( - &increment.files, - |file_plan, - worker| - -> Result { + let file_materializations = hotpath::measure_block!( + "code_index.collect.materialize_increment", + collect_bounded_ordered( + &increment.files, + |file_plan, + worker| + -> Result { Self::checkpoint(control)?; match &file_plan.action { FileExtractionActionV1::CarryForward { @@ -1860,7 +1874,8 @@ where Ok(IncrementFileMaterializationV1::Deleted) } } - }, + }, + ) )?; let mut files = Vec::new(); diff --git a/crates/tracedecay-code-index/src/retained_parse.rs b/crates/tracedecay-code-index/src/retained_parse.rs index 5458a83325..bacb1060d4 100644 --- a/crates/tracedecay-code-index/src/retained_parse.rs +++ b/crates/tracedecay-code-index/src/retained_parse.rs @@ -171,7 +171,6 @@ impl SharedRetainedParsePool { .map(|(report, _)| report) } - #[hotpath::measure(label = "code_index.collect.retained")] pub fn parse_and_extract( &self, identity: ParseDocumentIdentity, @@ -179,23 +178,24 @@ impl SharedRetainedParsePool { source: &str, extractor: &dyn LanguageExtractor, ) -> Result<(ParseReport, ParsedExtraction), ParseError> { - let (report, extraction) = - self.parse_and_extract_artifact(identity, language_id, source, extractor)?; - Ok(( - report, - ParsedExtraction { - result: extraction.artifact.result, - disposition: extraction.disposition, - metrics: extraction.metrics, - }, - )) + crate::hotpath_observe::measure_hot_loop!("code_index.collect.retained", { + let (report, extraction) = + self.parse_and_extract_artifact(identity, language_id, source, extractor)?; + Ok(( + report, + ParsedExtraction { + result: extraction.artifact.result, + disposition: extraction.disposition, + metrics: extraction.metrics, + }, + )) + }) } /// Parse and extract one full canonical artifact from the pool-owned tree. /// The retained artifact, including import bindings, is the previous-state /// authority for incremental merging; this path never acquires a second /// parser. - #[hotpath::measure(label = "code_index.collect.retained_artifact")] pub fn parse_and_extract_artifact( &self, identity: ParseDocumentIdentity, @@ -203,23 +203,24 @@ impl SharedRetainedParsePool { source: &str, extractor: &dyn LanguageExtractor, ) -> Result<(ParseReport, ParsedExtractionArtifactV1), ParseError> { - let grammar_key = extractor.retained_grammar_key(identity.logical_path()); - let prepared_source = extractor.prepare_parse_source(source); - let (report, extraction) = self.parse_internal( - identity, - language_id, - source, - prepared_source.as_ref(), - Some(&grammar_key), - Some(extractor), - )?; - match extraction { - Some(extraction) => Ok((report, extraction)), - None => Err(ParseError::ParseFailed), - } + crate::hotpath_observe::measure_hot_loop!("code_index.collect.retained_artifact", { + let grammar_key = extractor.retained_grammar_key(identity.logical_path()); + let prepared_source = extractor.prepare_parse_source(source); + let (report, extraction) = self.parse_internal( + identity, + language_id, + source, + prepared_source.as_ref(), + Some(&grammar_key), + Some(extractor), + )?; + match extraction { + Some(extraction) => Ok((report, extraction)), + None => Err(ParseError::ParseFailed), + } + }) } - #[hotpath::measure(label = "code_index.collect.parse")] fn parse_internal( &self, identity: ParseDocumentIdentity, @@ -229,119 +230,122 @@ impl SharedRetainedParsePool { grammar_key: Option<&str>, extractor: Option<&dyn LanguageExtractor>, ) -> Result<(ParseReport, Option), ParseError> { - if source.len() > self.limits.max_total_source_bytes { - self.record_failure(); - return Err(ParseError::SourceTooLarge { - size: source.len(), - limit: self.limits.max_total_source_bytes, - }); - } - let key = ParseDocumentKey::for_identity(&identity); - let (existing, admission_epoch) = { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - touch(&mut state.lru, &key); - (state.documents.get(&key).cloned(), state.clear_epoch) - }; - - match existing { - Some(entry) => self.parse_existing( - key, - entry, - identity, - language_id, - source, - prepared_source, - grammar_key, - extractor, - ), - None => { - // Serialize first admission per document. Unrelated documents - // parse concurrently; a second lookup after acquiring this - // key's gate keeps one retained tree for duplicate callers. - let first_admission = self.first_admission(&key); - let _first_admission_guard = first_admission - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + crate::hotpath_observe::measure_hot_loop!("code_index.collect.parse", { + if source.len() > self.limits.max_total_source_bytes { + self.record_failure(); + return Err(ParseError::SourceTooLarge { + size: source.len(), + limit: self.limits.max_total_source_bytes, + }); + } + let key = ParseDocumentKey::for_identity(&identity); + let (existing, admission_epoch) = { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(entry) = state.documents.get(&key).cloned() { - touch(&mut state.lru, &key); - drop(state); - return self.parse_existing( - key, - entry, - identity, - language_id, - source, - prepared_source, - grammar_key, - extractor, - ); - } - drop(state); - let opened = match grammar_key { - Some(grammar_key) => RetainedParseDocument::open_prepared( - identity, - language_id, - grammar_key, - source, - prepared_source, - self.limits.document, - ), - None => RetainedParseDocument::open( - identity, - language_id, - source, - self.limits.document, - ), - }; - let (document, report) = match opened { - Ok(parsed) => parsed, - Err(error) => { - self.record_failure_at(admission_epoch); - return Err(error); + touch(&mut state.lru, &key); + (state.documents.get(&key).cloned(), state.clear_epoch) + }; + + match existing { + Some(entry) => self.parse_existing( + key, + entry, + identity, + language_id, + source, + prepared_source, + grammar_key, + extractor, + ), + None => { + // Serialize first admission per document. Unrelated documents + // parse concurrently; a second lookup after acquiring this + // key's gate keeps one retained tree for duplicate callers. + let first_admission = self.first_admission(&key); + let _first_admission_guard = first_admission + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(entry) = state.documents.get(&key).cloned() { + touch(&mut state.lru, &key); + drop(state); + return self.parse_existing( + key, + entry, + identity, + language_id, + source, + prepared_source, + grammar_key, + extractor, + ); } - }; - let extraction = match extractor { - Some(extractor) => { - match document.extract_canonical_artifact(extractor, &report, None) { - Ok(extraction) => Some(extraction), - Err(error) => { - self.record_failure_at(admission_epoch); - return Err(error); + drop(state); + let opened = match grammar_key { + Some(grammar_key) => RetainedParseDocument::open_prepared( + identity, + language_id, + grammar_key, + source, + prepared_source, + self.limits.document, + ), + None => RetainedParseDocument::open( + identity, + language_id, + source, + self.limits.document, + ), + }; + let (document, report) = match opened { + Ok(parsed) => parsed, + Err(error) => { + self.record_failure_at(admission_epoch); + return Err(error); + } + }; + let extraction = match extractor { + Some(extractor) => { + match document.extract_canonical_artifact(extractor, &report, None) { + Ok(extraction) => Some(extraction), + Err(error) => { + self.record_failure_at(admission_epoch); + return Err(error); + } } } + None => None, + }; + let retained_artifact = + extraction.as_ref().map(|parsed| parsed.artifact.clone()); + let current_size = document.retained_source_bytes(); + let entry = Arc::new(Mutex::new(RetainedEntry { + document, + artifact: retained_artifact, + })); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.clear_epoch != admission_epoch { + return Ok((report, extraction)); } - None => None, - }; - let retained_artifact = extraction.as_ref().map(|parsed| parsed.artifact.clone()); - let current_size = document.retained_source_bytes(); - let entry = Arc::new(Mutex::new(RetainedEntry { - document, - artifact: retained_artifact, - })); - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if state.clear_epoch != admission_epoch { - return Ok((report, extraction)); + state.documents.insert(key.clone(), Arc::clone(&entry)); + state.source_bytes.insert(key.clone(), current_size); + touch(&mut state.lru, &key); + evict_to_limits(&mut state, &key, self.limits); + record_success(&mut state.stats, &report, extraction.as_ref()); + state.stats.retained_documents = state.documents.len(); + state.stats.retained_source_bytes = state.source_bytes.values().copied().sum(); + Ok((report, extraction)) } - state.documents.insert(key.clone(), Arc::clone(&entry)); - state.source_bytes.insert(key.clone(), current_size); - touch(&mut state.lru, &key); - evict_to_limits(&mut state, &key, self.limits); - record_success(&mut state.stats, &report, extraction.as_ref()); - state.stats.retained_documents = state.documents.len(); - state.stats.retained_source_bytes = state.source_bytes.values().copied().sum(); - Ok((report, extraction)) } - } + }) } #[allow(clippy::too_many_arguments)] From 41a66a44ae9f85a8a0cee05003736a9dbb1a8dec Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 05:51:42 +0000 Subject: [PATCH 42/69] fix(hotpath): name build queryability latency truthfully --- .../tracedecay-code-index/src/hotpath_observe.rs | 15 ++++++++++----- .../tracedecay-code-index/src/production/mod.rs | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/tracedecay-code-index/src/hotpath_observe.rs b/crates/tracedecay-code-index/src/hotpath_observe.rs index 20a3ef2c66..80438c2d22 100644 --- a/crates/tracedecay-code-index/src/hotpath_observe.rs +++ b/crates/tracedecay-code-index/src/hotpath_observe.rs @@ -320,21 +320,26 @@ pub(crate) fn record_seal_bytes(bytes: u64) { } } -pub(crate) struct TtfqStart(#[cfg(feature = "hotpath")] Instant); +/// Start of one production-owner generation build. The matching observation +/// ends only after the immutable generation has been published and is +/// queryable through that owner; daemon scheduling/wake latency is measured +/// separately by the reconcile cadence receipt. +pub(crate) struct BuildToQueryableStart(#[cfg(feature = "hotpath")] Instant); #[inline(always)] -pub(crate) fn start_ttfq() -> TtfqStart { - TtfqStart( +pub(crate) fn start_build_to_queryable() -> BuildToQueryableStart { + BuildToQueryableStart( #[cfg(feature = "hotpath")] Instant::now(), ) } #[inline(always)] -pub(crate) fn record_ttfq(started: TtfqStart) { +pub(crate) fn record_build_to_queryable(started: BuildToQueryableStart) { #[cfg(feature = "hotpath")] { - hotpath::gauge!("code_index_ttfq_micros").set(started.0.elapsed().as_micros() as f64); + hotpath::gauge!("code_index_build_to_queryable_micros") + .set(started.0.elapsed().as_micros() as f64); } #[cfg(not(feature = "hotpath"))] { diff --git a/crates/tracedecay-code-index/src/production/mod.rs b/crates/tracedecay-code-index/src/production/mod.rs index 868460826b..6a734b309f 100644 --- a/crates/tracedecay-code-index/src/production/mod.rs +++ b/crates/tracedecay-code-index/src/production/mod.rs @@ -1270,7 +1270,7 @@ where request: CodeIndexBuildRequestV1, control: &dyn CodeIndexExecutionControlV1, ) -> Result, CodeIndexProductionErrorV1> { - let started = crate::hotpath_observe::start_ttfq(); + let started = crate::hotpath_observe::start_build_to_queryable(); crate::hotpath_observe::record_generation_state("building"); crate::hotpath_observe::record_rebuild_state("unknown"); Self::checkpoint(control)?; @@ -1469,7 +1469,7 @@ where self.publication .publish_atomically(&scope, expected.as_ref(), Arc::clone(&candidate))?; crate::hotpath_observe::record_generation_state("queryable"); - crate::hotpath_observe::record_ttfq(started); + crate::hotpath_observe::record_build_to_queryable(started); Ok(candidate) } From 4d777993346191456cc794b3fdc946d3a8de9090 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 05:57:58 +0000 Subject: [PATCH 43/69] fix(hotpath): sample extraction digest phases --- crates/tracedecay-code-index/src/extract.rs | 6 +++--- crates/tracedecay-code-index/src/hotpath_observe.rs | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/tracedecay-code-index/src/extract.rs b/crates/tracedecay-code-index/src/extract.rs index 94e3c83b30..b739d6598b 100644 --- a/crates/tracedecay-code-index/src/extract.rs +++ b/crates/tracedecay-code-index/src/extract.rs @@ -442,7 +442,7 @@ fn rows_digest( .map(CanonicalUnresolvedRefRow::from) .collect::>(); let mut imports = artifact.imports.clone(); - hotpath::measure_block!("code_index_rows_digest_sort", { + crate::hotpath_observe::measure_hot_loop!("code_index_rows_digest_sort", { sort_canonical_rows(&mut nodes); sort_canonical_rows(&mut edges); sort_canonical_rows(&mut unresolved); @@ -463,7 +463,7 @@ fn rows_digest( unresolved_refs: Vec>, } - hotpath::measure_block!( + crate::hotpath_observe::measure_hot_loop!( "code_index_rows_digest_hash", canonical_sha256(&RowsPayload { separator: EXTRACTION_ROWS_SEPARATOR, @@ -486,7 +486,7 @@ fn rows_digest( pub(crate) fn parser_import_rows_digest( imports: &[ExtractedImportEvidenceV1], ) -> Result { - hotpath::measure_block!("code_index_parser_import_rows_digest", { + crate::hotpath_observe::measure_hot_loop!("code_index_parser_import_rows_digest", { let mut imports = imports.to_vec(); imports.sort(); canonical_sha256(&(PARSER_IMPORT_ROWS_DIGEST_SEPARATOR, imports.as_slice())).map_err( diff --git a/crates/tracedecay-code-index/src/hotpath_observe.rs b/crates/tracedecay-code-index/src/hotpath_observe.rs index 80438c2d22..03dd2000b6 100644 --- a/crates/tracedecay-code-index/src/hotpath_observe.rs +++ b/crates/tracedecay-code-index/src/hotpath_observe.rs @@ -29,7 +29,7 @@ static WORKERS_POOL_COORDINATION: AtomicUsize = AtomicUsize::new(0); #[cfg(feature = "hotpath")] static HOT_LOOP_SAMPLE: AtomicU64 = AtomicU64::new(0); -#[cfg(any(feature = "hotpath", test))] +#[cfg(feature = "hotpath")] #[must_use] #[inline(always)] fn is_hot_loop_sample(sequence: u64) -> bool { @@ -375,8 +375,9 @@ pub(crate) fn record_rebuild_state(state: &'static str) { mod tests { use super::*; + #[cfg(feature = "hotpath")] #[test] - fn hot_loop_sampling_uses_one_fixed_slot_per_period() { + fn hotpath_file_probe_cadence_uses_one_fixed_slot_per_period() { let sampled = (0..(HOT_LOOP_SAMPLE_PERIOD * 2)) .filter(|sequence| is_hot_loop_sample(*sequence)) .collect::>(); @@ -384,6 +385,12 @@ mod tests { assert_eq!(sampled, vec![0, HOT_LOOP_SAMPLE_PERIOD]); } + #[cfg(not(feature = "hotpath"))] + #[test] + fn hotpath_file_probe_sampler_is_dormant_without_the_feature() { + assert!((0..(HOT_LOOP_SAMPLE_PERIOD * 2)).all(|_| !sample_hot_loop())); + } + #[test] fn pending_queue_decrements_when_each_worker_starts() { let queue = PendingWorkQueue::new(3); From 1fcc4f6e5b66315a237ef2e6923661891dcb8e94 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 06:15:45 +0000 Subject: [PATCH 44/69] test(index): await incremental text serving --- src/daemon/code_index_scheduler/tests.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 422667ec7a..db5caaab4e 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -1560,10 +1560,25 @@ fn saved_edit_incremental_publish() { !latest.graph_edges().is_empty() || !latest.graph_abstentions().is_empty(), "graph lane must remain explicitly queryable" ); + let mut text_passes = 0_usize; + while !latest + .advance_text_serving(64) + .expect("advance bounded production text serving") + { + text_passes += 1; + assert!( + text_passes < 10_000, + "incremental generation text serving never became ready" + ); + } let owners = latest .production_query_owners() .expect("production exact/lexical/graph owners connect"); - let _ = owners.is_artifact_backed(); + assert!( + owners.is_artifact_backed(), + "incremental publish must serve real durable exact/lexical owners" + ); + latest.warm_serving_caches(); let _ = latest .production_graph_serving() .expect("graph owner is activated"); From 65a7e9a54826e5002027141b2aae0bce09d6ad1a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 06:27:05 +0000 Subject: [PATCH 45/69] perf(hotpath): bound capture progress cadence --- .../code_index_scheduler/git_tree_capture.rs | 78 +++++++++++++++---- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/src/daemon/code_index_scheduler/git_tree_capture.rs b/src/daemon/code_index_scheduler/git_tree_capture.rs index 2e15662055..c7150edf9e 100644 --- a/src/daemon/code_index_scheduler/git_tree_capture.rs +++ b/src/daemon/code_index_scheduler/git_tree_capture.rs @@ -97,10 +97,13 @@ impl CaptureProgressV1 { pub(super) fn observe_candidate(&self, bytes: usize) { #[cfg(feature = "hotpath")] { - self.candidate_files.fetch_add(1, Ordering::Relaxed); + let candidate_files = self + .candidate_files + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1); self.candidate_bytes .fetch_add(bytes as u64, Ordering::Relaxed); - self.publish_at_cadence(false); + self.publish_at_cadence(candidate_files, false); } #[cfg(not(feature = "hotpath"))] let _ = bytes; @@ -110,10 +113,13 @@ impl CaptureProgressV1 { pub(super) fn observe_processed(&self, bytes: usize) { #[cfg(feature = "hotpath")] { - self.processed_files.fetch_add(1, Ordering::Relaxed); + let processed_files = self + .processed_files + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1); self.processed_bytes .fetch_add(bytes as u64, Ordering::Relaxed); - self.publish_at_cadence(false); + self.publish_at_cadence(processed_files, false); } #[cfg(not(feature = "hotpath"))] let _ = bytes; @@ -123,10 +129,13 @@ impl CaptureProgressV1 { pub(super) fn observe_captured(&self, bytes: usize) { #[cfg(feature = "hotpath")] { - self.captured_files.fetch_add(1, Ordering::Relaxed); + let captured_files = self + .captured_files + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1); self.captured_bytes .fetch_add(bytes as u64, Ordering::Relaxed); - self.publish_at_cadence(false); + self.publish_at_cadence(captured_files, false); } #[cfg(not(feature = "hotpath"))] let _ = bytes; @@ -134,17 +143,13 @@ impl CaptureProgressV1 { #[cfg(feature = "hotpath")] #[inline(always)] - fn publish_at_cadence(&self, force: bool) { + fn publish_at_cadence(&self, observation_count: u64, force: bool) { + if !force && !Self::cadence_is_due(observation_count) { + return; + } let candidate_files = self.candidate_files.load(Ordering::Relaxed); let processed_files = self.processed_files.load(Ordering::Relaxed); let captured_files = self.captured_files.load(Ordering::Relaxed); - if !force - && !candidate_files.is_multiple_of(CAPTURE_PROGRESS_UPDATE_PERIOD) - && !processed_files.is_multiple_of(CAPTURE_PROGRESS_UPDATE_PERIOD) - && !captured_files.is_multiple_of(CAPTURE_PROGRESS_UPDATE_PERIOD) - { - return; - } hotpath::gauge!("code_index.capture.candidate_files").set(candidate_files); hotpath::gauge!("code_index.capture.candidate_bytes") .set(self.candidate_bytes.load(Ordering::Relaxed)); @@ -155,12 +160,18 @@ impl CaptureProgressV1 { hotpath::gauge!("code_index.capture.captured_bytes") .set(self.captured_bytes.load(Ordering::Relaxed)); } + + #[cfg(feature = "hotpath")] + #[inline(always)] + fn cadence_is_due(observation_count: u64) -> bool { + observation_count != 0 && observation_count.is_multiple_of(CAPTURE_PROGRESS_UPDATE_PERIOD) + } } impl Drop for CaptureProgressV1 { fn drop(&mut self) { #[cfg(feature = "hotpath")] - self.publish_at_cadence(true); + self.publish_at_cadence(0, true); } } @@ -589,6 +600,43 @@ mod tests { branch_generations, classify_capture_failure, }; + #[cfg(feature = "hotpath")] + use super::{CAPTURE_PROGRESS_UPDATE_PERIOD, CaptureProgressV1}; + + #[cfg(feature = "hotpath")] + #[test] + fn zero_captured_large_tree_uses_bounded_candidate_cadence() { + let candidate_count = CAPTURE_PROGRESS_UPDATE_PERIOD * 64; + let progress = CaptureProgressV1::new(); + for _ in 0..candidate_count { + progress.observe_candidate(1); + } + assert_eq!( + progress + .candidate_files + .load(std::sync::atomic::Ordering::Relaxed), + candidate_count + ); + assert_eq!( + progress + .captured_files + .load(std::sync::atomic::Ordering::Relaxed), + 0 + ); + + let publish_points = (1..=candidate_count) + .filter(|count| CaptureProgressV1::cadence_is_due(*count)) + .collect::>(); + + assert_eq!(publish_points.len(), 64); + assert_eq!( + publish_points.first().copied(), + Some(CAPTURE_PROGRESS_UPDATE_PERIOD) + ); + assert_eq!(publish_points.last().copied(), Some(candidate_count)); + assert!(!CaptureProgressV1::cadence_is_due(0)); + } + fn git(root: &Path, arguments: &[&str]) { let status = Command::new(crate::git::git_program()) .current_dir(root) From fe431fe3d645d0551c3b5ef14065b40648495a69 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 07:10:39 +0000 Subject: [PATCH 46/69] fix(ci): restore test transport compilation --- crates/tracedecay-runtime-core/src/privacy/rules.rs | 4 ++-- src/daemon/code_index_scheduler/git_tree_capture.rs | 9 ++++----- .../code_index_scheduler/registry/resident_memory.rs | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/tracedecay-runtime-core/src/privacy/rules.rs b/crates/tracedecay-runtime-core/src/privacy/rules.rs index 8a3d7b6a19..5ea13d5095 100644 --- a/crates/tracedecay-runtime-core/src/privacy/rules.rs +++ b/crates/tracedecay-runtime-core/src/privacy/rules.rs @@ -1446,10 +1446,10 @@ mod tests { let expected = compiled .iter() - .map(|pattern| pattern.keywords_present(&source)) + .map(|pattern| pattern.keywords_present(source)) .collect::>(); - assert_eq!(compiled.keyword_presence(&source), expected); + assert_eq!(compiled.keyword_presence(source), expected); } #[test] diff --git a/src/daemon/code_index_scheduler/git_tree_capture.rs b/src/daemon/code_index_scheduler/git_tree_capture.rs index c7150edf9e..b872ea78b8 100644 --- a/src/daemon/code_index_scheduler/git_tree_capture.rs +++ b/src/daemon/code_index_scheduler/git_tree_capture.rs @@ -93,7 +93,7 @@ impl CaptureProgressV1 { } } - #[inline(always)] + #[inline] pub(super) fn observe_candidate(&self, bytes: usize) { #[cfg(feature = "hotpath")] { @@ -109,7 +109,7 @@ impl CaptureProgressV1 { let _ = bytes; } - #[inline(always)] + #[inline] pub(super) fn observe_processed(&self, bytes: usize) { #[cfg(feature = "hotpath")] { @@ -125,7 +125,7 @@ impl CaptureProgressV1 { let _ = bytes; } - #[inline(always)] + #[inline] pub(super) fn observe_captured(&self, bytes: usize) { #[cfg(feature = "hotpath")] { @@ -863,8 +863,7 @@ mod tests { let captured = scheduler .capture_exact_git_tree_snapshot( &ExactGitTreeSourceV1 { - reference: tracedecay_domain::RefId::new("refs/heads/main") - .expect("reference"), + reference: tracedecay_domain::RefId::new("refs/heads/main").expect("reference"), revision: tracedecay_domain::CommitId::new(revision).expect("revision"), tree: tracedecay_domain::TreeId::new(tree).expect("tree"), }, diff --git a/src/daemon/code_index_scheduler/registry/resident_memory.rs b/src/daemon/code_index_scheduler/registry/resident_memory.rs index 561771e917..5b730fe36c 100644 --- a/src/daemon/code_index_scheduler/registry/resident_memory.rs +++ b/src/daemon/code_index_scheduler/registry/resident_memory.rs @@ -20,7 +20,7 @@ impl CodeIndexSchedulerRegistryV1 { ) } - #[cfg(test)] + #[cfg(any(test, feature = "test-transport"))] pub fn with_resident_memory( max_worktrees: usize, resident_memory: Arc, From 31d0c68f20e7d5492b8f6179b7a7e690d513b115 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 07:13:18 +0000 Subject: [PATCH 47/69] perf(query): localize ngram catch-up writes --- .../lexical/projection/artifact/builder.rs | 206 +++++++++++++++++- .../lexical/projection/artifact/format.rs | 73 ++++++- .../candidate_producers.rs | 2 +- 3 files changed, 272 insertions(+), 9 deletions(-) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index babef9cb06..6d2a3d0be8 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -30,7 +30,8 @@ use super::format::{ CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1, CodeLexicalArtifactSectionDigestV1, RECEIPT_RESERVATION_BYTES, SECTION_NAMES, VerifiedCodeLexicalArtifactV1, artifact_digest, decode_padded_receipt, decode_padded_receipt_with_control, encode_field, metadata_digest, - new_verified_receipt, padded_receipt, verify_required_artifact_indexes, + new_verified_receipt, padded_receipt, verify_artifact_table_layout, + verify_required_artifact_indexes, }; use super::postings::document_ngram_scratch; use super::prepared::{PreparedCodeLexicalArtifactPageV1, prepare_page as prepare_page_values}; @@ -613,6 +614,7 @@ impl CodeLexicalArtifactBuilderV1 { require_integrity(&connection, control)?; let expected_digest = metadata_digest(&expected_metadata)?; verify_artifact_state_metadata(&connection, &expected_metadata, &expected_digest, control)?; + verify_artifact_table_layout(&connection)?; let receipt = read_receipt_with_control(&connection, control)?; let finalization = load_finalization_state(&connection)?; if receipt.is_some() @@ -2396,7 +2398,7 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError kind INTEGER NOT NULL, ngram INTEGER NOT NULL, document_id INTEGER NOT NULL, - PRIMARY KEY(kind, ngram, document_id) + PRIMARY KEY(document_id, kind, ngram) ) WITHOUT ROWID; CREATE TABLE vocabulary (term TEXT PRIMARY KEY) WITHOUT ROWID; CREATE TRIGGER content_epoch_source_pages_insert AFTER INSERT ON source_pages BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; @@ -2749,9 +2751,9 @@ fn build_serving_index_step( ) ), 6 => hotpath::measure_block!( - "query.artifact.finalization.index.ngram_postings_by_document", + "query.artifact.finalization.index.ngram_postings_by_ngram", transaction.execute_batch( - "CREATE INDEX ngram_postings_by_document ON ngram_postings(document_id, kind, ngram)", + "CREATE UNIQUE INDEX ngram_postings_by_ngram ON ngram_postings(kind, ngram, document_id)", ) ), _ => { @@ -4092,6 +4094,10 @@ mod tests { use super::*; use rusqlite::StatementStatus; use std::sync::atomic::{AtomicUsize, Ordering}; + use tracedecay_domain::{ + CodeGenerationId, ComponentRevision, FreshnessCompatibilityV1, ScoreDomainId, + SourceFreshness, SourceInstanceKey, SourceNamespace, UtcMicros, + }; struct ActiveControl; @@ -4110,6 +4116,35 @@ mod tests { observations: AtomicUsize, } + fn test_metadata() -> CodeLexicalProjectionMetadataV1 { + CodeLexicalProjectionMetadataV1 { + generation: CodeGenerationId::new("generation.artifact-builder.v1") + .expect("generation"), + repository_id: None, + logical_paths: Default::default(), + freshness: SourceFreshness { + source_namespace: SourceNamespace::new("namespace.artifact-builder") + .expect("namespace"), + source_instance: SourceInstanceKey::new("instance.artifact-builder") + .expect("instance"), + source_watermark: None, + projection_watermark: None, + observed_at: UtcMicros(0), + source_generation: None, + generation_lag: None, + compatibility: FreshnessCompatibilityV1::Unknown, + policy_revision: ComponentRevision::new("policy.artifact-builder.v1") + .expect("policy"), + }, + exact_retriever_revision: ComponentRevision::new("retriever.exact.artifact.v1") + .expect("exact retriever"), + lexical_retriever_revision: ComponentRevision::new("retriever.lexical.artifact.v1") + .expect("lexical retriever"), + exact_score_domain: ScoreDomainId::new("score.exact.artifact.v1") + .expect("score domain"), + } + } + impl CodeIndexExecutionControlV1 for CancelAtCheckpoint { fn is_cancelled(&self) -> bool { self.observations.fetch_add(1, Ordering::SeqCst) + 1 >= self.checkpoint @@ -4168,6 +4203,163 @@ mod tests { .expect("progress handler is cleared after spawn failure"); } + #[test] + fn ngram_staging_key_preserves_document_order_without_a_serving_index() { + let connection = Connection::open_in_memory().expect("open artifact database"); + create_schema(&connection).expect("create artifact schema"); + for (document_id, ngram) in [(0i64, 90i64), (0, 100), (1, 10), (1, 20)] { + connection + .execute( + "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (1, ?1, ?2)", + params![ngram, document_id], + ) + .expect("seed document-ordered ngram posting"); + } + + let serving_indexes: i64 = connection + .query_row( + "SELECT COUNT(*) FROM pragma_index_list('ngram_postings') WHERE name = 'ngram_postings_by_ngram'", + [], + |row| row.get(0), + ) + .expect("inspect staging indexes"); + assert_eq!( + serving_indexes, 0, + "serving-key maintenance must remain absent during catch-up" + ); + + let mut statement = connection + .prepare( + "SELECT document_id, kind, ngram FROM ngram_postings ORDER BY document_id, kind, ngram", + ) + .expect("prepare staging-order scan"); + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .expect("scan staging order") + .collect::, _>>() + .expect("collect staging order"); + assert_eq!(rows, [(0, 1, 90), (0, 1, 100), (1, 1, 10), (1, 1, 20)]); + assert_eq!( + statement.get_status(StatementStatus::Sort), + 0, + "document-order catch-up must be the maintained table order" + ); + } + + #[test] + fn deferred_ngram_serving_index_is_unique_and_query_selective() { + let mut connection = Connection::open_in_memory().expect("open artifact database"); + create_schema(&connection).expect("create artifact schema"); + for (document_id, ngram) in [(1i64, 10i64), (1, 20), (2, 10)] { + connection + .execute( + "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (1, ?1, ?2)", + params![ngram, document_id], + ) + .expect("seed ngram posting"); + } + + let transaction = connection + .transaction() + .expect("start serving-index transaction"); + build_serving_index_step(&transaction, 6).expect("build ngram serving index"); + transaction.commit().expect("commit ngram serving index"); + + let unique: i64 = connection + .query_row( + "SELECT [unique] FROM pragma_index_list('ngram_postings') WHERE name = 'ngram_postings_by_ngram'", + [], + |row| row.get(0), + ) + .expect("inspect ngram serving index"); + assert_eq!( + unique, 1, + "the serving index must preserve posting identity" + ); + let columns = connection + .prepare( + "SELECT name FROM pragma_index_xinfo('ngram_postings_by_ngram') WHERE key = 1 ORDER BY seqno", + ) + .expect("prepare serving-index columns") + .query_map([], |row| row.get::<_, String>(0)) + .expect("query serving-index columns") + .collect::, _>>() + .expect("collect serving-index columns"); + assert_eq!(columns, ["kind", "ngram", "document_id"]); + + let query = "SELECT document_id FROM ngram_postings \ + WHERE kind = ?1 AND ngram IN (?2, ?3) \ + GROUP BY document_id HAVING COUNT(DISTINCT ngram) = ?4 \ + ORDER BY document_id"; + let plan = connection + .prepare(&format!("EXPLAIN QUERY PLAN {query}")) + .expect("prepare ngram serving plan") + .query_map(params![1i64, 10i64, 20i64, 2i64], |row| { + row.get::<_, String>(3) + }) + .expect("query ngram serving plan") + .collect::, _>>() + .expect("collect ngram serving plan"); + assert!( + plan.iter() + .any(|detail| detail.contains("USING COVERING INDEX ngram_postings_by_ngram")), + "phrase candidates must use the deferred serving index, got {plan:?}" + ); + let documents = connection + .prepare(query) + .expect("prepare ngram serving query") + .query_map(params![1i64, 10i64, 20i64, 2i64], |row| { + row.get::<_, i64>(0) + }) + .expect("query ngram candidates") + .collect::, _>>() + .expect("collect ngram candidates"); + assert_eq!(documents, [1]); + } + + #[test] + fn resume_refuses_the_superseded_ngram_staging_layout() { + let directory = tempfile::tempdir().expect("artifact tempdir"); + let path = directory.path().join("superseded-ngram-layout.sqlite"); + let metadata = test_metadata(); + drop( + CodeLexicalArtifactBuilderV1::create(&path, metadata.clone()) + .expect("create current staging artifact"), + ); + let connection = Connection::open(&path).expect("open staging artifact for fixture setup"); + connection + .execute_batch( + "ALTER TABLE ngram_postings RENAME TO current_ngram_postings; + CREATE TABLE ngram_postings ( + kind INTEGER NOT NULL, + ngram INTEGER NOT NULL, + document_id INTEGER NOT NULL, + PRIMARY KEY(kind, ngram, document_id) + ) WITHOUT ROWID; + DROP TABLE current_ngram_postings;", + ) + .expect("install superseded branch-local layout"); + drop(connection); + + let error = + match CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &path, + metadata, + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &ActiveControl, + ) { + Ok(_) => panic!("resume must not mix superseded and current ngram layouts"), + Err(error) => error, + }; + assert!(matches!(error, CodeLexicalArtifactErrorV1::Incompatible(_))); + } + #[test] fn persisted_progress_tail_seeks_latest_maintained_cursor_without_full_scan() { let connection = Connection::open_in_memory().expect("open progress database"); @@ -4406,6 +4598,12 @@ mod tests { connection .execute("INSERT INTO vocabulary(term) VALUES ('term')", []) .expect("seed vocabulary"); + let transaction = connection + .unchecked_transaction() + .expect("start ngram serving-index transaction"); + build_serving_index_step(&transaction, 6) + .expect("build ngram serving index before digest verification"); + transaction.commit().expect("commit ngram serving index"); for section in FinalizationSectionV1::ALL { let plan = explain_native_seek_plan(&connection, section) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs index 33abf93e24..e2841c8e55 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs @@ -20,8 +20,8 @@ use super::CodeLexicalArtifactErrorV1; // Revision 3 replaces the branch-local computed finalization cursor with // native table keys. Revision 4 adds document-leading indexes. Revision 5 // adds term-selective read indexes. Revision 6 makes the append authority -// immutable before one authenticated digest pass and defers every serving -// index until resumable finalization. +// immutable before one authenticated digest pass, defers every serving index +// until resumable finalization, and keeps ngram catch-up document-leading. pub(super) const CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1: u32 = 6; const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"tracedecay.code-lexical-artifact.v6\0"; const REQUIRED_ARTIFACT_INDEXES_V6: [(&str, &str, &[&str]); 7] = [ @@ -49,8 +49,8 @@ const REQUIRED_ARTIFACT_INDEXES_V6: [(&str, &str, &[&str]); 7] = [ ), ( "ngram_postings", - "ngram_postings_by_document", - &["document_id", "kind", "ngram"], + "ngram_postings_by_ngram", + &["kind", "ngram", "document_id"], ), ]; pub(super) const RECEIPT_RESERVATION_BYTES: usize = 16 * 1024; @@ -71,6 +71,7 @@ pub(super) const SECTION_NAMES: [&str; 11] = [ pub(super) fn verify_required_artifact_indexes( connection: &Connection, ) -> Result<(), CodeLexicalArtifactErrorV1> { + verify_artifact_table_layout(connection)?; let mut statement = connection .prepare("SELECT name, desc, coll FROM pragma_index_xinfo(?1) WHERE key = 1 ORDER BY seqno") .map_err(|error| { @@ -126,6 +127,70 @@ pub(super) fn verify_required_artifact_indexes( Ok(()) } +pub(super) fn verify_artifact_table_layout( + connection: &Connection, +) -> Result<(), CodeLexicalArtifactErrorV1> { + let without_rowid: Option = connection + .query_row( + "SELECT wr FROM pragma_table_list WHERE schema = 'main' AND name = 'ngram_postings' AND type = 'table'", + [], + |row| row.get(0), + ) + .optional() + .map_err(|error| { + CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact ngram table schema is unreadable: {error}" + )) + })?; + let mut statement = connection + .prepare( + "SELECT name, type, [notnull], pk FROM pragma_table_xinfo('ngram_postings') WHERE hidden = 0 ORDER BY cid", + ) + .map_err(|error| { + CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact ngram columns are unreadable: {error}" + )) + })?; + let columns = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + }) + .map_err(|error| { + CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact ngram columns are unreadable: {error}" + )) + })? + .collect::, _>>() + .map_err(|error| { + CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact ngram columns are unreadable: {error}" + )) + })?; + let expected = [ + ("kind", "INTEGER", 1, 2), + ("ngram", "INTEGER", 1, 3), + ("document_id", "INTEGER", 1, 1), + ]; + if without_rowid != Some(1) + || !columns + .iter() + .map(|(name, column_type, not_null, primary_key)| { + (name.as_str(), column_type.as_str(), *not_null, *primary_key) + }) + .eq(expected) + { + return Err(CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact ngram table has columns {columns:?} and without-rowid state {without_rowid:?}; revision {CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1} requires a document-leading primary key" + ))); + } + Ok(()) +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct CodeLexicalArtifactSectionDigestV1 { 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 d53b9f5e01..a025fde822 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -1344,7 +1344,7 @@ fn disk_artifact_defers_statistics_and_serving_indexes_until_freeze() { serving_indexes, [ "exact_postings_by_document", - "ngram_postings_by_document", + "ngram_postings_by_ngram", "rows_by_chunk", "term_postings_by_document", "term_postings_by_document_term", From f2aae139f0ed59aba2aa508b658094f221ee6845 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 08:10:52 +0000 Subject: [PATCH 48/69] fix(index): preserve graph-off text catch-up --- src/daemon/code_index_scheduler.rs | 163 ++++++++++- src/daemon/code_index_scheduler/registry.rs | 179 ++++++++---- src/daemon/code_index_scheduler/tests.rs | 306 +++++++++++++++++++- 3 files changed, 577 insertions(+), 71 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 1a101e6e08..1c7a77280f 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -38,8 +38,8 @@ use tracedecay_private_fs::{ }; use tracedecay_runtime_core::resident_memory::{ DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, ProcessResidentMemoryV1, - ResidentMemoryAdmissionFailureV1, - ResidentMemoryComponentIdV1, ResidentMemoryKeyV1, ResidentMemoryReservationV1, + ResidentMemoryAdmissionFailureV1, ResidentMemoryComponentIdV1, ResidentMemoryKeyV1, + ResidentMemoryReservationV1, }; use tracedecay_usecases::code_index::{ DaemonCodeIndexControlV1, ProductionCodeIndexOwnerV1, open_production_code_index_owner_v1, @@ -3076,7 +3076,7 @@ impl LatestCodeTextGenerationV1 { })?; let mut remaining = maximum_work.min(TEXT_ARTIFACT_MAXIMUM_WORK_PER_ADVANCE_V1); while remaining > 0 && artifact_build.source_receipt.is_none() { - let maximum_batch_pages = remaining.min(TEXT_ARTIFACT_BATCH_PAGES_V1).max(1); + let maximum_batch_pages = remaining.clamp(1, TEXT_ARTIFACT_BATCH_PAGES_V1); let bounds = VerifiedSealedLexicalPageBatchBoundsV1::new( maximum_batch_pages, TEXT_ARTIFACT_BATCH_BYTES_V1, @@ -3989,6 +3989,108 @@ impl CodeIndexWorktreeSchedulerV1 { ))) } + /// Verify an unchanged retained text generation without decoding the full + /// graph-bearing generation. + /// + /// Graph-off mounts already authenticated the complete sealed bytes while + /// opening their lexical page source. For an ordinary source roster, the + /// durable freshness witness proves a quiet mount; an explicit hint is + /// settled by one authoritative capture whose snapshot identity must equal + /// the authenticated text metadata. A changed source or an ignored-source + /// roster falls back to the complete reconcile path. + fn reconcile_retained_text_generation( + &mut self, + metadata: &VerifiedSealedTextGenerationMetadataV1, + ) -> Result, CodeIndexSchedulerErrorV1> { + if self.shutting_down.load(Ordering::Acquire) { + return Err(cancelled_code_index_reconcile()); + } + if metadata.manifest().project_id != self.project_id + || metadata.snapshot().repository != self.repository_id + || metadata.snapshot().worktree.as_ref() != Some(&self.worktree_id) + { + return Ok(None); + } + let Some(witness) = RestoreFreshnessWitnessV1::load(&self.store_root) else { + return Ok(None); + }; + if witness.generation_id != metadata.manifest().generation_id.as_str() + || !witness.ignored_source_paths.is_empty() + || !self.ignored_source_admissions.is_empty() + { + return Ok(None); + } + let sampled_metadata = identity::GitMetadataFingerprintV1::capture(&self.project_root); + let Some(sampled_signature) = self.worktree_stat_signature().ok() else { + return Ok(None); + }; + let has_hints = { + let hints = self + .hints + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + hints.overflow || !hints.paths.is_empty() + }; + if !has_hints { + if witness.git_metadata_signature != sampled_metadata.stable_signature() + || witness.stat_signature != sampled_signature + { + return Ok(None); + } + let snapshot_content_identity = metadata.snapshot().content_identity.clone(); + self.latest_content_identity = Some(snapshot_content_identity.clone()); + self.mark_reconciled_state(sampled_metadata, Some(sampled_signature)); + return Ok(Some(CodeIndexReconcileOutcomeV1::Noop( + CodeIndexNoopEvidenceV1 { + snapshot_content_identity, + overflow_reconciled: false, + }, + ))); + } + + let capture_epoch = self.epoch.load(Ordering::Acquire); + let mut captured = + self.capture_authoritative_snapshot_without_active_generation_reuse(None)?; + if captured.snapshot.reference != metadata.snapshot().reference + || captured.snapshot.source_revision != metadata.snapshot().source_revision + || captured.snapshot.content_identity != metadata.snapshot().content_identity + { + return Ok(None); + } + let hints = { + let mut hints = self + .hints + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.epoch.load(Ordering::Acquire) != capture_epoch { + return Ok(None); + } + hints.take() + }; + drop(std::mem::take(&mut captured.captured_files)); + Self::finish_snapshot_build_memory(&mut captured.retained_reservations)?; + self.retained_snapshot_bytes = std::mem::take(&mut captured.retained_bytes); + self._retained_snapshot_memory = std::mem::take(&mut captured.retained_reservations); + let snapshot_content_identity = captured.snapshot.content_identity; + self.latest_content_identity = Some(snapshot_content_identity.clone()); + self.mark_reconciled_state(sampled_metadata.clone(), Some(sampled_signature.clone())); + RestoreFreshnessWitnessV1 { + generation_id: witness.generation_id, + git_metadata_signature: sampled_metadata.stable_signature(), + stat_signature: sampled_signature, + repository_parse_identity_digest: witness.repository_parse_identity_digest, + ignored_source_admissions_digest: witness.ignored_source_admissions_digest, + ignored_source_paths: witness.ignored_source_paths, + } + .persist(&self.store_root); + Ok(Some(CodeIndexReconcileOutcomeV1::Noop( + CodeIndexNoopEvidenceV1 { + snapshot_content_identity, + overflow_reconciled: hints.overflow, + }, + ))) + } + /// Load a complete identity-valid generation for stale serving. /// /// This does not claim freshness: a cancelled refresh or live ref switch @@ -4337,6 +4439,15 @@ impl CodeIndexWorktreeSchedulerV1 { &mut self, metadata: identity::GitMetadataFingerprintV1, signature: Option, + ) { + self.mark_reconciled_state(metadata, signature); + self.persist_freshness_witness(); + } + + fn mark_reconciled_state( + &mut self, + metadata: identity::GitMetadataFingerprintV1, + signature: Option, ) { self.git_metadata = metadata; self.last_stat_signature = signature; @@ -4344,7 +4455,6 @@ impl CodeIndexWorktreeSchedulerV1 { self.last_reconciled_at = Instant::now(); self.last_reconciled_at_micros = Some(now_micros().0); self.verified_against_source = true; - self.persist_freshness_witness(); } /// Record the restore-time freshness witness for the current active @@ -4433,12 +4543,25 @@ impl CodeIndexWorktreeSchedulerV1 { &mut self, admission: GenerationDecodeAdmissionV1, ) -> Result, CodeIndexSchedulerErrorV1> { - let latest = self.latest_complete_ready_for_query_with(admission)?; - if latest.is_none() { + if self.shutting_down.load(Ordering::Acquire) { + return Err(cancelled_code_index_reconcile()); + } + if self.freshness_unknown + || identity::GitMetadataFingerprintV1::capture(&self.project_root) + .differs_from(&self.git_metadata) + { + self.request_background_reconcile(); return Ok(None); } match self.worktree_stat_signature() { - Ok(signature) if self.last_stat_signature.as_ref() == Some(&signature) => Ok(latest), + Ok(signature) if self.last_stat_signature.as_ref() == Some(&signature) => { + // The exact-source stat fence is stronger than the elapsed + // tier-2 arm. Refresh only the monotonic admission clock: no + // reconcile receipt or wall timestamp is fabricated, and a + // clean status census cannot turn into a full capture loop. + self.last_reconciled_at = Instant::now(); + Ok(self.latest_complete_with(admission)) + } _ => { self.request_background_reconcile(); Ok(None) @@ -4856,13 +4979,7 @@ impl CodeIndexWorktreeSchedulerV1 { .ignored_source_admissions .iter() .any(|admission| admission.logical_path == logical_path); - self.capture_admitted_candidate( - registry, - logical_path, - control, - None, - explicitly_admitted, - ) + self.capture_admitted_candidate(registry, logical_path, control, None, explicitly_admitted) } fn ignored_admission_paths(&self) -> BTreeSet<&str> { @@ -4904,6 +5021,21 @@ impl CodeIndexWorktreeSchedulerV1 { fn capture_authoritative_snapshot( &self, control: Option<&dyn CodeIndexExecutionControlV1>, + ) -> Result { + self.capture_authoritative_snapshot_with_active_generation_reuse(control, true) + } + + fn capture_authoritative_snapshot_without_active_generation_reuse( + &self, + control: Option<&dyn CodeIndexExecutionControlV1>, + ) -> Result { + self.capture_authoritative_snapshot_with_active_generation_reuse(control, false) + } + + fn capture_authoritative_snapshot_with_active_generation_reuse( + &self, + control: Option<&dyn CodeIndexExecutionControlV1>, + allow_active_generation_reuse: bool, ) -> Result { if self.shutting_down.load(Ordering::Acquire) { return Err(cancelled_code_index_reconcile()); @@ -4921,7 +5053,8 @@ impl CodeIndexWorktreeSchedulerV1 { if self.shutting_down.load(Ordering::Acquire) { return Err(cancelled_code_index_reconcile()); } - if self.ignored_source_admissions.is_empty() + if allow_active_generation_reuse + && self.ignored_source_admissions.is_empty() && classification.changes().is_empty() && let (Some(reference), Some(revision), Some(tree)) = ( self.identity.head_ref(), diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index d8cc1a6fc6..36a905b860 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -2413,6 +2413,7 @@ impl CodeIndexSchedulerRegistryV1 { let scheduler = Arc::clone(&worker_scheduler); let serving_generation = Arc::clone(&worker_serving_generation); let serving_generation_epoch = Arc::clone(&worker_serving_generation_epoch); + let graph_activation_enabled = worker_graph_activation.policy().is_enabled(); // Cover wake claim through failed-arrival restoration so admission // never misreads in-flight owner work as plain unavailability. let _reconcile_pass = @@ -2440,8 +2441,7 @@ impl CodeIndexSchedulerRegistryV1 { if matches!( &error, tracedecay_query::retrieval::RetrievalPortError::Cancelled - ) - { + ) { let mut current = worker_text_generation .write() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -2472,6 +2472,11 @@ impl CodeIndexSchedulerRegistryV1 { return; } if text_slice_incomplete { + if !graph_activation_enabled { + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.slice.continue_total").inc(1_u64); + continue; + } if Self::incomplete_text_slice_may_continue(&worker_pending_wake) { #[cfg(feature = "hotpath")] hotpath::gauge!("query.artifact.slice.continue_total").inc(1_u64); @@ -2513,10 +2518,11 @@ impl CodeIndexSchedulerRegistryV1 { // split must not hide a sealed generation for the duration // of reconcile. Stale is truthful; do not mark_reconciled. let mut seat_retry_pending = false; - if serving_generation - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none() + if graph_activation_enabled + && serving_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() { let remount_scheduler = Arc::clone(&scheduler); let remount = tokio::task::spawn_blocking(move || { @@ -2621,17 +2627,38 @@ impl CodeIndexSchedulerRegistryV1 { } continue; } + let retained_text_metadata = (!graph_activation_enabled).then(|| { + worker_text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|text| text.metadata().clone()) + }); let mut result = tokio::task::spawn_blocking(move || { let mut scheduler = scheduler .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut result = scheduler.activate_or_reconcile(); + let mut result = if graph_activation_enabled { + scheduler.activate_or_reconcile() + } else if let Some(metadata) = retained_text_metadata.flatten() { + match scheduler.reconcile_retained_text_generation(&metadata) { + Ok(Some(outcome)) => Ok(outcome), + Ok(None) => scheduler.reconcile_now(), + Err(error) => Err(error), + } + } else { + scheduler.reconcile_now() + }; // A terminal outcome may publish a newer complete generation; // swap serving to that after graph activation below. - let mut latest = result - .as_ref() - .ok() - .and_then(|_| scheduler.latest_complete()); + let mut latest = graph_activation_enabled + .then(|| { + result + .as_ref() + .ok() + .and_then(|_| scheduler.latest_complete()) + }) + .flatten(); let replay_binding = latest.as_ref().map(|latest| { scheduler.code_graph_replay_binding( &latest.generation().manifest().generation_id, @@ -3571,12 +3598,13 @@ impl CodeIndexSchedulerRegistryV1 { let project_root = project_root.canonicalize().ok()?; // Clone the per-worktree handle under a short map lock, then drop the // registry guard before checking the mounted route. - let (scheduler, serving_generation, hints, wake, pending_wake) = { + let (scheduler, serving_generation, text_generation, hints, wake, pending_wake) = { let mounted = self.mounted.lock().await; let worktree = mounted.get(&project_root)?; ( Arc::clone(&worktree.scheduler), Arc::clone(&worktree.serving_generation), + Arc::clone(&worktree.text_generation), Arc::clone(&worktree.hints), Arc::clone(&worktree.wake), Arc::clone(&worktree.pending_wake), @@ -3637,6 +3665,18 @@ impl CodeIndexSchedulerRegistryV1 { } return Some(latest); } + // A graph-off mount can already own authenticated text serving + // while its graph-bearing generation deliberately remains + // unseated. That owner is a real remedy for lexical/exact reads; + // do not misclassify it as a cold open and inject an overflow that + // would supersede its bounded projection. + if text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + { + return None; + } // Cold open has no servable generation. Verification and any // rebuild stay with the retained owner; reads only request the // wake and return typed unavailable/unverified. @@ -3812,6 +3852,19 @@ impl CodeIndexSchedulerRegistryV1 { Arc::clone(&worktree.serving_generation), ) }; + // The census asks only whether a fully decoded generation is already + // seated. A graph-off mount deliberately leaves this slot empty while + // its authenticated text owner is warming. Return that known answer + // before entering the exact freshness probe: probing an unseated slot + // cannot produce a decoded owner, and on an initial lightweight mount + // it would turn `freshness_unknown` into a fabricated overflow wake. + if serving_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() + { + return None; + } let mut scheduler = match scheduler.try_lock() { Ok(scheduler) => scheduler, Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(), @@ -3870,13 +3923,28 @@ impl CodeIndexSchedulerRegistryV1 { // MCP search resolves its generation before it asks for query authority, // so this is the first authenticated demand boundary on that path. self.activate_for_scope(scope); - let root = { + let (root, graph_activation_enabled, text_generation) = { let mounted = self.mounted.try_lock().ok()?; - unique_mounted_for_scope(&mounted, scope) - .unique()? - .0 - .clone() + let (root, worktree) = unique_mounted_for_scope(&mounted, scope).unique()?; + ( + root.clone(), + worktree.graph_activation.policy().is_enabled(), + Arc::clone(&worktree.text_generation), + ) }; + // Graph-off mounts authenticate the sealed text source before its + // bounded projection becomes ready. During that window the text owner + // is the canonical warming authority; falling through to AwaitDecode + // would reconstruct the graph-bearing generation solely to report the + // same typed unavailability, defeating the lightweight cutover. + if !graph_activation_enabled + && text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + { + return None; + } let latest = self.latest_complete_ready_with(&root, admission).await?; // Checkout-identity gate: the ready ladder verified currency against // the live worktree, so a scope whose branch label was resolved on @@ -3991,7 +4059,7 @@ impl CodeIndexSchedulerRegistryV1 { } else { None }; - let (scheduler, serving_generation, hints, wake, pending_wake) = { + let (scheduler, serving_generation, text_generation, hints, wake, pending_wake) = { let Ok(mounted) = self.mounted.try_lock() else { return false; }; @@ -4001,6 +4069,7 @@ impl CodeIndexSchedulerRegistryV1 { ( Arc::clone(&worktree.scheduler), Arc::clone(&worktree.serving_generation), + Arc::clone(&worktree.text_generation), Arc::clone(&worktree.hints), Arc::clone(&worktree.wake), Arc::clone(&worktree.pending_wake), @@ -4047,7 +4116,11 @@ impl CodeIndexSchedulerRegistryV1 { let nothing_servable = serving_generation .read() .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none(); + .is_none() + && text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none(); // Nothing is servable at all, so the ladder's suppression cannot // apply: a reconcile is the only thing that can ever make this scope // answerable, and no other caller on this path will ask for it. @@ -4247,40 +4320,6 @@ impl CodeIndexSchedulerRegistryV1 { } } -#[cfg(test)] -mod text_slice_fairness_tests { - use super::{CodeIndexCadenceTriggerV1, CodeIndexSchedulerRegistryV1, PendingWakeV1}; - - #[test] - fn pending_reconcile_is_serviced_between_bounded_text_slices() { - let pending = PendingWakeV1::default(); - let wake = tokio::sync::Notify::new(); - assert!( - CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), - "a text-only self-wake may advance the next bounded slice" - ); - - CodeIndexSchedulerRegistryV1::note_wake( - &pending, - &wake, - CodeIndexCadenceTriggerV1::HookHint, - ); - assert!( - !CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), - "a pending source reconcile must win before another text slice" - ); - - let _ = CodeIndexSchedulerRegistryV1::take_pending_arrival( - &pending, - CodeIndexCadenceTriggerV1::Mount, - ); - assert!( - CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), - "text continuation resumes only after reconcile claims the pending arrival" - ); - } -} - impl tracedecay_usecases::feedback::cycle_production::ProductionFeedbackDocumentIdentityPort for CodeIndexSchedulerRegistryV1 { @@ -4447,3 +4486,37 @@ fn feedback_document_logical_path( .map(|path| path.replace('\\', "/")) .ok_or_else(|| LspRuntimeFailure::new("feedback-document-path-unavailable")) } + +#[cfg(test)] +mod text_slice_fairness_tests { + use super::{CodeIndexCadenceTriggerV1, CodeIndexSchedulerRegistryV1, PendingWakeV1}; + + #[test] + fn pending_reconcile_is_serviced_between_bounded_text_slices() { + let pending = PendingWakeV1::default(); + let wake = tokio::sync::Notify::new(); + assert!( + CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), + "a text-only self-wake may advance the next bounded slice" + ); + + CodeIndexSchedulerRegistryV1::note_wake( + &pending, + &wake, + CodeIndexCadenceTriggerV1::HookHint, + ); + assert!( + !CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), + "a pending source reconcile must win before another text slice" + ); + + let _ = CodeIndexSchedulerRegistryV1::take_pending_arrival( + &pending, + CodeIndexCadenceTriggerV1::Mount, + ); + assert!( + CodeIndexSchedulerRegistryV1::incomplete_text_slice_may_continue(&pending), + "text continuation resumes only after reconcile claims the pending arrival" + ); + } +} diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index db5caaab4e..3b2343c80d 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -4025,7 +4025,7 @@ fn source_epoch_advance_does_not_discard_immutable_text_progress() { }; assert!( - matches!(result, Ok(false) | Ok(true)), + matches!(result, Ok(false | true)), "a worktree freshness epoch must not cancel immutable generation work: {result:?}" ); assert!( @@ -5417,7 +5417,14 @@ async fn dashboard_progress_does_not_wait_for_the_scheduler_mutex() { .await .expect("background text build publishes progress"); - let scheduler_guard = scheduler.lock().expect("hold scheduler mutex"); + let (locked_tx, locked_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let scheduler_holder = tokio::task::spawn_blocking(move || { + let _scheduler_guard = scheduler.lock().expect("hold scheduler mutex"); + let _ = locked_tx.send(()); + let _ = release_rx.blocking_recv(); + }); + locked_rx.await.expect("scheduler mutex holder started"); let projected = tokio::time::timeout( Duration::from_secs(1), registry.dashboard_freshness(fixture.path()), @@ -5428,7 +5435,10 @@ async fn dashboard_progress_does_not_wait_for_the_scheduler_mutex() { let projected_progress = projected.progress.expect("projected progress snapshot"); assert_eq!(projected_progress.generation_id, expected.generation_id); assert!(projected_progress.progress_epoch >= expected.progress_epoch); - drop(scheduler_guard); + let _ = release_tx.send(()); + scheduler_holder + .await + .expect("scheduler mutex holder joined"); registry.shutdown().await; } @@ -9858,6 +9868,296 @@ async fn resident_memory_graph_refusal_seats_text_serving_without_graph() { registry.shutdown().await; } +/// A graph-off cold mount must keep the authenticated lightweight text owner +/// authoritative when an overflow reconcile arrives between bounded text +/// slices. Rebinding the same sealed generation through the full-generation +/// cache clears the live progress slot, invalidates the surviving owner's +/// epoch, and decodes gigabytes that graph-off serving never needs. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() { + let sources = (0..512) + .map(|index| { + ( + format!("src/file_{index:04}.rs"), + format!("pub fn alpha_{index:04}() -> usize {{ {index} }}\n"), + ) + }) + .collect::>(); + let source_refs = sources + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect::>(); + let fixture = GitFixture::new(&source_refs); + let store = TempDir::new().expect("store root"); + let scoped_store = super::scoped_code_index_store_root( + store.path(), + &fixture.path().canonicalize().expect("canonical fixture"), + ); + let (scope, privacy_domain) = { + let mut scheduler = scheduler( + &fixture, + scoped_store, + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("seed retained generation")); + let latest = scheduler.latest_complete().expect("seeded generation"); + let snapshot = latest.generation.snapshot(); + ( + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + snapshot.worktree.clone().expect("worktree id"), + snapshot.reference.clone(), + ) + .expect("resolved scope"), + latest.generation.manifest().privacy_domain.clone(), + ) + }; + + let registry = CodeIndexSchedulerRegistryV1::with_background_reconcile_permits(1, 1); + registry + .mount_worktree_with_graph_policy( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + super::CodeGraphActivationPolicyV1::RefusedByConfiguration, + ) + .await + .expect("mount graph-off scheduler"); + registry + .mount_query_authority(fixture.path(), &scope, query_authority(privacy_domain)) + .await + .expect("mount query authority"); + let scheduler = { + let mounted = registry.mounted.lock().await; + Arc::clone( + &mounted + .get(&fixture.path().canonicalize().expect("canonical root")) + .expect("mounted worktree") + .scheduler, + ) + }; + + let progress_deadline = std::time::Instant::now() + Duration::from_secs(10); + let (owner_epoch_before_overflow, progress_before_overflow) = loop { + let observed = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (progress.owner_epoch, progress.snapshot()) + }; + if let (owner_epoch, Some(progress)) = observed + && progress.committed_pages > 0 + && progress.phase + != crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + { + break (owner_epoch, progress); + } + assert!( + std::time::Instant::now() <= progress_deadline, + "text projection completed before exposing bounded live progress" + ); + tokio::task::yield_now().await; + }; + let status_poll_admission = registry + .background_reconcile_admission() + .acquire_owned() + .await + .expect("pause text projection after a bounded committed slice"); + let last_reconciled_before_status_polls = { + let mut scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + scheduler.policy.staleness_threshold = Duration::ZERO; + scheduler.last_reconciled_at_micros() + }; + for _ in 0..8 { + assert!( + !registry.has_current_ready_decoded_for_root_scope(fixture.path(), &scope), + "graph-off status census has no fully decoded generation" + ); + } + { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!( + scheduler.pending_hint_count(), + Some(0), + "stat-equal status polling must not fabricate an overflow reconcile" + ); + assert_eq!( + scheduler.last_reconciled_at_micros(), + last_reconciled_before_status_polls, + "stat-equal status polling must not run a capture pass" + ); + assert_eq!( + scheduler.sealed_decode_count(), + 0, + "graph-off status polling must not decode the full generation" + ); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(progress.owner_epoch, owner_epoch_before_overflow); + let progress = progress + .snapshot() + .expect("status polling preserves live text progress"); + assert_eq!( + progress.daemon_incarnation, + progress_before_overflow.daemon_incarnation + ); + assert_eq!( + progress.producer_incarnation, + progress_before_overflow.producer_incarnation + ); + assert!(progress.progress_epoch >= progress_before_overflow.progress_epoch); + assert!(progress.committed_pages >= progress_before_overflow.committed_pages); + } + drop(status_poll_admission); + let last_reconciled_before_overflow = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .last_reconciled_at_micros(); + assert!( + registry.notify_hook_overflow(fixture.path()).await, + "mounted graph-off worktree accepts the overflow reconcile" + ); + + let query_deadline = std::time::Instant::now() + Duration::from_secs(10); + let executed = loop { + match registry + .execute_query_search(&scope, core_search_request("alpha_0000")) + .await + { + Ok(executed) => break executed, + Err(error) => { + assert!( + std::time::Instant::now() <= query_deadline, + "graph-off text projection never became queryable: {error}" + ); + tokio::task::yield_now().await; + } + } + }; + let overflow_deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let overflow_settled = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + scheduler.pending_hint_count() == Some(0) + && scheduler.last_reconciled_at_micros() != last_reconciled_before_overflow + }; + if overflow_settled + && !registry + .reconcile_in_progress_for_test(fixture.path()) + .await + { + break; + } + assert!( + std::time::Instant::now() <= overflow_deadline, + "graph-off overflow did not settle through a real no-op reconcile" + ); + tokio::task::yield_now().await; + } + let (owner_epoch_after_overflow, progress_after_overflow, decode_count) = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let decode_count = scheduler.sealed_decode_count(); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + ( + progress.owner_epoch, + progress + .snapshot() + .expect("ready text progress remains visible"), + decode_count, + ) + }; + assert_eq!(owner_epoch_after_overflow, owner_epoch_before_overflow); + assert_eq!( + progress_after_overflow.generation_id, + progress_before_overflow.generation_id + ); + assert_eq!( + progress_after_overflow.daemon_incarnation, + progress_before_overflow.daemon_incarnation + ); + assert_eq!( + progress_after_overflow.producer_incarnation, + progress_before_overflow.producer_incarnation + ); + assert!(progress_after_overflow.progress_epoch > progress_before_overflow.progress_epoch); + assert!(progress_after_overflow.committed_pages >= progress_before_overflow.committed_pages); + assert_eq!( + progress_after_overflow.phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + ); + assert_eq!( + decode_count, 0, + "graph-off text serving must not decode the full generation" + ); + let artifact_names = std::fs::read_dir(super::code_text_artifacts_root( + &super::scoped_code_index_store_root(store.path(), fixture.path()), + )) + .expect("read text artifact root") + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + artifact_names + .iter() + .filter(|name| name.starts_with("text-artifact-") && name.ends_with(".bin")) + .count(), + 1, + "one durable artifact owns ready text serving" + ); + assert_eq!( + artifact_names + .iter() + .filter(|name| name.starts_with(".text-artifact-") && name.ends_with(".staging")) + .count(), + 0, + "ready text serving leaves no abandoned staging owner" + ); + assert_eq!( + executed + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::ExactLiteral), + Some(&PublicRetrieverStatus::Complete) + ); + assert_eq!( + executed + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::Lexical), + Some(&PublicRetrieverStatus::Complete) + ); + assert_eq!( + executed + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::Graph), + Some(&PublicRetrieverStatus::Unavailable) + ); + registry.shutdown().await; +} + /// The canonical project setting must reach the scheduler's production policy /// boundary. A configured refusal still verifies and seats the sealed text /// generation, while the graph authority is never activated. From 24b59eedeb6d9e9a5c3b796a06d86962fc8e2523 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 08:53:45 +0000 Subject: [PATCH 49/69] fix(index): advance graph-off serving after source changes --- src/daemon/code_index_scheduler.rs | 210 +++++++++++++++-- src/daemon/code_index_scheduler/registry.rs | 33 +++ src/daemon/code_index_scheduler/tests.rs | 238 ++++++++++++++++++++ 3 files changed, 456 insertions(+), 25 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 1c7a77280f..13aa0d713b 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -455,6 +455,21 @@ struct PublicationPointerMemoV1 { pointer: DurablePublicationPointerV1, } +#[derive(Clone)] +struct UndecodedActivePublicationExpectationV1 { + generation_id: String, + generation_file: String, + state_digest: String, +} + +impl UndecodedActivePublicationExpectationV1 { + fn matches(&self, pointer: &DurablePublicationPointerV1) -> bool { + self.generation_id == pointer.generation_id + && self.generation_file == pointer.generation_file + && self.state_digest == pointer.state_digest + } +} + #[derive(Clone)] struct DaemonCodeIndexPublicationStoreV1 { cache: Arc, @@ -465,6 +480,7 @@ struct DaemonCodeIndexPublicationStoreV1 { expected_sanitizer_revision: SanitizerRevision, disposition: CodeIndexPublicationDispositionV1, pointer_memo: Arc>>, + undecoded_active_expectation: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -516,9 +532,20 @@ impl DaemonCodeIndexPublicationStoreV1 { expected_sanitizer_revision, disposition: CodeIndexPublicationDispositionV1::Active, pointer_memo: Arc::new(Mutex::new(None)), + undecoded_active_expectation: None, }) } + fn for_undecoded_active_rebuild(&self, pointer: &DurablePublicationPointerV1) -> Self { + let mut publication = self.clone(); + publication.undecoded_active_expectation = Some(UndecodedActivePublicationExpectationV1 { + generation_id: pointer.generation_id.clone(), + generation_file: pointer.generation_file.clone(), + state_digest: pointer.state_digest.clone(), + }); + publication + } + fn retained_history(&self) -> Self { let mut retained = self.clone(); retained.disposition = CodeIndexPublicationDispositionV1::RetainedHistory; @@ -1162,6 +1189,9 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { &self, _scope: &CodeIndexGenerationScopeV1, ) -> Result, CodeIndexPublicationStoreErrorV1> { + if self.undecoded_active_expectation.is_some() { + return Ok(None); + } Ok(self .load_active_shared()? .map(|generation| generation.as_ref().clone())) @@ -1180,14 +1210,34 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { .ok_or_else(|| Self::unavailable("active code-generation pointer has no store root"))?; let _store_lock = acquire_code_generation_store_lock(store_root).map_err(Self::unavailable)?; - let _ = self.load_active_shared()?; + let prior_pointer = if let Some(expected) = self.undecoded_active_expectation.as_ref() { + if expected_active_generation.is_some() { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + let pointer = self + .read_publication_pointer()? + .ok_or(CodeIndexPublicationStoreErrorV1::CompareAndSwap)?; + if !expected.matches(&pointer) { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } + Some(pointer) + } else { + let _ = self.load_active_shared()?; + self.read_publication_pointer()? + }; let state = self.cache.lock_state()?; - if state + let cached_active = state .active .as_ref() - .map(|current| ¤t.manifest().generation_id) - != expected_active_generation - { + .map(|current| ¤t.manifest().generation_id); + let cache_matches = self.undecoded_active_expectation.as_ref().map_or( + cached_active == expected_active_generation, + |expected| { + cached_active + .is_none_or(|generation| generation.as_str() == expected.generation_id.as_str()) + }, + ); + if !cache_matches { return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); } if self.disposition == CodeIndexPublicationDispositionV1::RetainedHistory @@ -1268,7 +1318,6 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { Err(error) => return Err(Self::unavailable(error)), } - let prior_pointer = self.read_publication_pointer()?; let exact_git_evidence = self.exact_git_evidence(&generation)?; let mut generation_index = prior_pointer .as_ref() @@ -1371,25 +1420,31 @@ impl CodeIndexAtomicPublicationPort for DaemonCodeIndexPublicationStoreV1 { Ok::<(), CodeIndexPublicationStoreErrorV1>(()) })?; let mut state = self.cache.lock_state()?; - if state - .active - .as_ref() - .map(|current| ¤t.manifest().generation_id) - != expected_active_generation - { - return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + if self.undecoded_active_expectation.is_none() { + let cached_active = state + .active + .as_ref() + .map(|current| ¤t.manifest().generation_id); + if cached_active != expected_active_generation { + return Err(CodeIndexPublicationStoreErrorV1::CompareAndSwap); + } } let generation_id = generation.manifest().generation_id.clone(); state.forget(&generation_id); match self.disposition { CodeIndexPublicationDispositionV1::Active => { - self.active_encoded_bytes - .store(generation_size, Ordering::Release); // The published generation is already decoded and validated in // memory. Bumping the epoch retires any decode that started // against the prior pointer so it cannot install over this one. state.active_epoch = state.active_epoch.wrapping_add(1); - state.active = Some(generation); + if self.undecoded_active_expectation.is_some() { + self.active_encoded_bytes.store(0, Ordering::Release); + state.active = None; + } else { + self.active_encoded_bytes + .store(generation_size, Ordering::Release); + state.active = Some(generation); + } } CodeIndexPublicationDispositionV1::RetainedHistory => { state.decoded.push_back(generation); @@ -3995,9 +4050,11 @@ impl CodeIndexWorktreeSchedulerV1 { /// Graph-off mounts already authenticated the complete sealed bytes while /// opening their lexical page source. For an ordinary source roster, the /// durable freshness witness proves a quiet mount; an explicit hint is - /// settled by one authoritative capture whose snapshot identity must equal - /// the authenticated text metadata. A changed source or an ignored-source - /// roster falls back to the complete reconcile path. + /// settled by one authoritative capture. An unchanged capture keeps the + /// retained generation, while a changed ordinary source is rebuilt from + /// that capture under an exact durable-pointer compare-and-swap. This path + /// never decodes the graph-bearing active generation. Ignored-source + /// rosters still require the complete reconcile path. fn reconcile_retained_text_generation( &mut self, metadata: &VerifiedSealedTextGenerationMetadataV1, @@ -4020,6 +4077,14 @@ impl CodeIndexWorktreeSchedulerV1 { { return Ok(None); } + let resolved = identity::IndexingIdentityV1::resolve(&self.project_root) + .map_err(|error| CodeIndexSchedulerErrorV1::Identity(error.to_string()))?; + if !resolved.authorizes_reuse_of(&self.identity) { + return Err(CodeIndexSchedulerErrorV1::Identity( + "worktree identity changed under the scheduler".to_owned(), + )); + } + self.identity = resolved; let sampled_metadata = identity::GitMetadataFingerprintV1::capture(&self.project_root); let Some(sampled_signature) = self.worktree_stat_signature().ok() else { return Ok(None); @@ -4051,12 +4116,6 @@ impl CodeIndexWorktreeSchedulerV1 { let capture_epoch = self.epoch.load(Ordering::Acquire); let mut captured = self.capture_authoritative_snapshot_without_active_generation_reuse(None)?; - if captured.snapshot.reference != metadata.snapshot().reference - || captured.snapshot.source_revision != metadata.snapshot().source_revision - || captured.snapshot.content_identity != metadata.snapshot().content_identity - { - return Ok(None); - } let hints = { let mut hints = self .hints @@ -4067,6 +4126,107 @@ impl CodeIndexWorktreeSchedulerV1 { } hints.take() }; + if captured.snapshot.reference != metadata.snapshot().reference + || captured.snapshot.source_revision != metadata.snapshot().source_revision + || captured.snapshot.content_identity != metadata.snapshot().content_identity + { + let pointer = self + .publication + .read_publication_pointer() + .map_err(CodeIndexProductionErrorV1::Publication)? + .ok_or_else(|| { + CodeIndexSchedulerErrorV1::PublicationConflict( + "the retained text generation has no active durable publication".to_owned(), + ) + })?; + if pointer.generation_id != metadata.manifest().generation_id.as_str() + || pointer.snapshot_content_identity + != metadata.snapshot().content_identity.as_str() + { + return Err(CodeIndexSchedulerErrorV1::PublicationConflict( + "the retained text generation was superseded before rebuild".to_owned(), + )); + } + let publication = self.publication.for_undecoded_active_rebuild(&pointer); + let mut owner = open_production_code_index_owner_v1( + self.production_config.clone(), + publication, + DaemonProjectionSinkV1, + ) + .map_err(|error| CodeIndexSchedulerErrorV1::ProductionOpen(error.to_string()))? + .with_physical_artifact_pool(self.byte_pool.physical_artifacts.clone()); + let control = DaemonCodeIndexControlV1::new( + Arc::clone(&self.epoch), + Arc::clone(&self.shutting_down), + ); + let snapshot_content_identity = captured.snapshot.content_identity.clone(); + let reextracted_files = captured.changed_paths.len(); + let generation = owner.build_and_publish( + CodeIndexBuildRequestV1 { + snapshot: captured.snapshot, + captured_files: captured.captured_files, + changed_files: captured.changed_paths, + invalidations: BTreeSet::new(), + repository_parse_identity: captured.repository_parse_identity, + ignored_source_admissions: Vec::new(), + sealed_at: now_micros(), + target_projection_key: projection_key()?, + }, + &control, + )?; + Self::finish_snapshot_build_memory(&mut captured.retained_reservations)?; + self.retained_snapshot_bytes = std::mem::take(&mut captured.retained_bytes); + self._retained_snapshot_memory = std::mem::take(&mut captured.retained_reservations); + self.latest_content_identity = Some(snapshot_content_identity); + self.mark_reconciled_state(sampled_metadata.clone(), Some(sampled_signature.clone())); + let repository_parse_identity_digest = + canonical_sha256(generation.repository_parse_identity()) + .map_err(|error| CodeIndexSchedulerErrorV1::Identity(error.to_string()))?; + RestoreFreshnessWitnessV1 { + generation_id: generation.manifest().generation_id.as_str().to_owned(), + git_metadata_signature: sampled_metadata.stable_signature(), + stat_signature: sampled_signature, + repository_parse_identity_digest: repository_parse_identity_digest + .as_str() + .to_owned(), + ignored_source_admissions_digest: generation + .ignored_source_admissions_digest() + .as_str() + .to_owned(), + ignored_source_paths: Vec::new(), + } + .persist(&self.store_root); + let changes = &generation.projection().request().changes; + let lane_digest = canonical_sha256(&( + generation.snapshot().content_identity.clone(), + generation + .chunks() + .chunks() + .iter() + .map(|chunk| (&chunk.id, &chunk.content_digest)) + .collect::>(), + generation.edges(), + )) + .map_err(|error| CodeIndexSchedulerErrorV1::Identity(error.to_string()))?; + return Ok(Some(CodeIndexReconcileOutcomeV1::Published( + CodeIndexPublishEvidenceV1 { + generation_id: generation.manifest().generation_id.clone(), + repository_id: self.repository_id.clone(), + snapshot_content_identity: generation.snapshot().content_identity.clone(), + _lane_digest: lane_digest, + _file_occurrence_ids: generation + .snapshot() + .files + .iter() + .map(|file| file.file_occurrence_id.clone()) + .collect(), + reextracted_files, + changed_chunks: changes.added_or_changed.len() + changes.deleted.len(), + reused_chunks: changes.reused.len(), + overflow_reconciled: hints.overflow, + }, + ))); + } drop(std::mem::take(&mut captured.captured_files)); Self::finish_snapshot_build_memory(&mut captured.retained_reservations)?; self.retained_snapshot_bytes = std::mem::take(&mut captured.retained_bytes); diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 36a905b860..efb72c187a 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -2675,6 +2675,39 @@ impl CodeIndexSchedulerRegistryV1 { (result, latest, replay_binding) }) .await; + if !graph_activation_enabled + && matches!( + &result, + Ok((Ok(CodeIndexReconcileOutcomeV1::Published(_)), None, None)) + ) + { + // Publication moved the durable pointer, so the prior text + // owner is no longer authoritative even while the new + // lightweight handle is opening. Withdraw it first: a + // failed or delayed reopen must report warming, never keep + // serving the superseded generation indefinitely. + *worker_text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + let text_scheduler = Arc::clone(&worker_scheduler); + let published_text = tokio::task::spawn_blocking(move || { + text_scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .servable_retained_text_generation() + }) + .await; + if let Ok(Some(published_text)) = published_text { + *worker_text_generation + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(published_text); + } + // A successful open advances B's bounded projection; an + // unavailable open retries the durable pointer without + // resurrecting A. + worker_wake.notify_one(); + } let replace_serving_generation = match &result { Ok((Ok(CodeIndexReconcileOutcomeV1::Noop(_)), Some(latest), Some(_))) => { let serving = worker_serving_generation diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 3b2343c80d..1afd247adf 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -10158,6 +10158,244 @@ async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() registry.shutdown().await; } +/// Publishing a changed graph-off generation must hand text authority from the +/// ready prior generation to the new durable pointer. Otherwise the worker's +/// graph-only `latest` slot stays empty, neither serving swap runs, and queries +/// can serve the old generation forever even though the pointer names newer +/// source. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graph_off_changed_source_advances_text_authority_without_full_decode() { + let sources = (0..256) + .map(|index| { + ( + format!("src/file_{index:04}.rs"), + format!("pub fn alpha_{index:04}() -> usize {{ {index} }}\n"), + ) + }) + .collect::>(); + let source_refs = sources + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect::>(); + let fixture = GitFixture::new(&source_refs); + let store = TempDir::new().expect("store root"); + let scoped_store = super::scoped_code_index_store_root( + store.path(), + &fixture.path().canonicalize().expect("canonical fixture"), + ); + let (scope, privacy_domain) = { + let mut scheduler = scheduler( + &fixture, + scoped_store, + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("seed retained generation")); + let latest = scheduler.latest_complete().expect("seeded generation"); + let snapshot = latest.generation.snapshot(); + ( + ResolvedScope::new( + test_project_id(), + snapshot.repository.clone(), + snapshot.worktree.clone().expect("worktree id"), + snapshot.reference.clone(), + ) + .expect("resolved scope"), + latest.generation.manifest().privacy_domain.clone(), + ) + }; + + let registry = CodeIndexSchedulerRegistryV1::with_background_reconcile_permits(1, 1); + registry + .mount_worktree_with_graph_policy( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + None, + super::CodeGraphActivationPolicyV1::RefusedByConfiguration, + ) + .await + .expect("mount graph-off scheduler"); + registry + .mount_query_authority(fixture.path(), &scope, query_authority(privacy_domain)) + .await + .expect("mount query authority"); + let scheduler = { + let mounted = registry.mounted.lock().await; + Arc::clone( + &mounted + .get(&fixture.path().canonicalize().expect("canonical root")) + .expect("mounted worktree") + .scheduler, + ) + }; + + let ready_deadline = std::time::Instant::now() + Duration::from_secs(10); + let generation_a = loop { + match registry + .execute_query_search(&scope, core_search_request("alpha_0000")) + .await + { + Ok(executed) => break executed.generation, + Err(error) => { + assert!( + std::time::Instant::now() <= ready_deadline, + "generation A never became text-queryable: {error}" + ); + tokio::task::yield_now().await; + } + } + }; + let owner_epoch_a = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(scheduler.sealed_decode_count(), 0); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!( + progress + .snapshot() + .expect("generation A ready progress") + .generation_id, + generation_a.as_str() + ); + progress.owner_epoch + }; + + fixture.edit( + "src/file_0000.rs", + "pub fn beta_0000() -> usize { 10_000 }\n", + ); + git(fixture.path(), &["commit", "-qam", "publish generation B"]); + assert!( + registry + .notify_hook_paths(fixture.path(), &["src/file_0000.rs".to_owned()]) + .await, + "changed source wakes the mounted graph-off owner" + ); + + let publication_deadline = std::time::Instant::now() + Duration::from_secs(10); + let generation_b = loop { + let durable = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + scheduler + .publication + .read_publication_pointer() + .expect("read durable active pointer") + .map(|pointer| pointer.generation_id) + }; + if let Some(durable) = durable + && durable != generation_a.as_str() + { + break CodeGenerationId::new(durable).expect("generation B id"); + } + assert!( + std::time::Instant::now() <= publication_deadline, + "changed graph-off source never published durable generation B" + ); + tokio::task::yield_now().await; + }; + + let progress_deadline = std::time::Instant::now() + Duration::from_secs(10); + let progress_b = loop { + let progress = { + let scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let progress = scheduler.build_progress_slot(); + let progress = progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (progress.owner_epoch, progress.snapshot()) + }; + if let (owner_epoch, Some(progress)) = progress + && progress.generation_id == generation_b.as_str() + && progress.committed_pages > 0 + { + assert!(owner_epoch > owner_epoch_a); + break progress; + } + assert!( + std::time::Instant::now() <= progress_deadline, + "durable generation B never acquired advancing text authority" + ); + tokio::task::yield_now().await; + }; + assert!(progress_b.progress_epoch > 0); + assert_eq!( + registry.latest_generation_id(fixture.path()).await, + Some(generation_b.clone()), + "generation A stops owning the graph-off query route" + ); + if let Ok(executed) = registry + .execute_query_search(&scope, core_search_request("alpha_0000")) + .await + { + assert_ne!( + executed.generation, generation_a, + "generation A must not serve after B owns text progress" + ); + } + + let query_deadline = std::time::Instant::now() + Duration::from_secs(10); + let executed_b = loop { + match registry + .execute_query_search(&scope, core_search_request("beta_0000")) + .await + { + Ok(executed) if executed.generation == generation_b => break executed, + Ok(executed) => panic!( + "stale generation {} served after durable B", + executed.generation + ), + Err(error) => { + assert!( + std::time::Instant::now() <= query_deadline, + "generation B never became text-queryable: {error}" + ); + tokio::task::yield_now().await; + } + } + }; + assert_eq!( + executed_b + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::ExactLiteral), + Some(&PublicRetrieverStatus::Complete) + ); + assert_eq!( + executed_b + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::Lexical), + Some(&PublicRetrieverStatus::Complete) + ); + assert_eq!( + executed_b + .authorized + .fallback + .public_fallback_lane_coverage + .get(&RetrieverKind::Graph), + Some(&PublicRetrieverStatus::Unavailable) + ); + assert_eq!( + scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .sealed_decode_count(), + 0, + "graph-off A-to-B publication must not decode a sealed generation" + ); + registry.shutdown().await; +} + /// The canonical project setting must reach the scheduler's production policy /// boundary. A configured refusal still verifies and seats the sealed text /// generation, while the graph authority is never activated. From fbc77c843eccdc65b73ec7b24ec052500dceb142 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 09:26:32 +0000 Subject: [PATCH 50/69] fix(index): preserve graph-off rebuild admission --- src/daemon/code_index_scheduler.rs | 108 +++++++++++---- src/daemon/code_index_scheduler/tests.rs | 168 +++++++++++++++++++++++ 2 files changed, 250 insertions(+), 26 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 13aa0d713b..7f88ba097e 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -1542,6 +1542,61 @@ impl PendingHintsV1 { fn take(&mut self) -> Self { std::mem::take(self) } + + fn restore(&mut self, pending: Self) { + if self.overflow { + return; + } + if pending.overflow { + self.overflow(); + return; + } + for path in pending.paths { + self.path(path); + if self.overflow { + break; + } + } + } +} + +/// A drained view of the canonical pending-hint authority. Until committed, +/// every early return, typed failure, cancellation, or unwind merges the exact +/// drained paths back with hints that arrived during the reconcile pass. +struct DrainedPendingHintsV1 { + authority: Arc>, + pending: Option, +} + +impl DrainedPendingHintsV1 { + fn new(authority: Arc>, pending: PendingHintsV1) -> Self { + Self { + authority, + pending: Some(pending), + } + } + + fn overflow(&self) -> bool { + self.pending + .as_ref() + .is_some_and(|pending| pending.overflow) + } + + fn commit(mut self) { + self.pending = None; + } +} + +impl Drop for DrainedPendingHintsV1 { + fn drop(&mut self) { + let Some(pending) = self.pending.take() else { + return; + }; + self.authority + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .restore(pending); + } } /// One candidate path's capture result, produced independently per file so @@ -4113,10 +4168,11 @@ impl CodeIndexWorktreeSchedulerV1 { ))); } + let _worker_memory = self.reserve_worker_memory()?; let capture_epoch = self.epoch.load(Ordering::Acquire); let mut captured = self.capture_authoritative_snapshot_without_active_generation_reuse(None)?; - let hints = { + let drained_hints = { let mut hints = self .hints .lock() @@ -4124,7 +4180,7 @@ impl CodeIndexWorktreeSchedulerV1 { if self.epoch.load(Ordering::Acquire) != capture_epoch { return Ok(None); } - hints.take() + DrainedPendingHintsV1::new(Arc::clone(&self.hints), hints.take()) }; if captured.snapshot.reference != metadata.snapshot().reference || captured.snapshot.source_revision != metadata.snapshot().source_revision @@ -4208,24 +4264,24 @@ impl CodeIndexWorktreeSchedulerV1 { generation.edges(), )) .map_err(|error| CodeIndexSchedulerErrorV1::Identity(error.to_string()))?; - return Ok(Some(CodeIndexReconcileOutcomeV1::Published( - CodeIndexPublishEvidenceV1 { - generation_id: generation.manifest().generation_id.clone(), - repository_id: self.repository_id.clone(), - snapshot_content_identity: generation.snapshot().content_identity.clone(), - _lane_digest: lane_digest, - _file_occurrence_ids: generation - .snapshot() - .files - .iter() - .map(|file| file.file_occurrence_id.clone()) - .collect(), - reextracted_files, - changed_chunks: changes.added_or_changed.len() + changes.deleted.len(), - reused_chunks: changes.reused.len(), - overflow_reconciled: hints.overflow, - }, - ))); + let outcome = CodeIndexReconcileOutcomeV1::Published(CodeIndexPublishEvidenceV1 { + generation_id: generation.manifest().generation_id.clone(), + repository_id: self.repository_id.clone(), + snapshot_content_identity: generation.snapshot().content_identity.clone(), + _lane_digest: lane_digest, + _file_occurrence_ids: generation + .snapshot() + .files + .iter() + .map(|file| file.file_occurrence_id.clone()) + .collect(), + reextracted_files, + changed_chunks: changes.added_or_changed.len() + changes.deleted.len(), + reused_chunks: changes.reused.len(), + overflow_reconciled: drained_hints.overflow(), + }); + drained_hints.commit(); + return Ok(Some(outcome)); } drop(std::mem::take(&mut captured.captured_files)); Self::finish_snapshot_build_memory(&mut captured.retained_reservations)?; @@ -4243,12 +4299,12 @@ impl CodeIndexWorktreeSchedulerV1 { ignored_source_paths: witness.ignored_source_paths, } .persist(&self.store_root); - Ok(Some(CodeIndexReconcileOutcomeV1::Noop( - CodeIndexNoopEvidenceV1 { - snapshot_content_identity, - overflow_reconciled: hints.overflow, - }, - ))) + let outcome = CodeIndexReconcileOutcomeV1::Noop(CodeIndexNoopEvidenceV1 { + snapshot_content_identity, + overflow_reconciled: drained_hints.overflow(), + }); + drained_hints.commit(); + Ok(Some(outcome)) } /// Load a complete identity-valid generation for stale serving. diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 1afd247adf..1860367734 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -1,5 +1,6 @@ use std::collections::BTreeSet; use std::fmt::Write as _; +use std::num::NonZeroU64; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Arc, OnceLock}; @@ -10158,6 +10159,107 @@ async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() registry.shutdown().await; } +/// A graph-off changed-source rebuild must use the same canonical worker-memory +/// admission as the complete reconcile path. A denial occurs before capture, +/// leaves the durable pointer and hint authority intact, and releases its RAII +/// reservation so an adequately funded retry can publish without decoding A. +#[test] +fn graph_off_changed_source_worker_memory_denial_retries_without_decode() { + let fixture = GitFixture::new(&[( + "src/lib.rs", + "pub fn graph_off_memory_alpha() -> usize { 1 }\n", + )]); + let store = TempDir::new().expect("store root"); + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("seed generation A")); + let generation_a = scheduler + .publication + .read_publication_pointer() + .expect("read generation A pointer") + .expect("generation A pointer"); + let metadata_a = scheduler + .servable_retained_text_generation() + .expect("verified generation A text handle") + .metadata() + .clone(); + + fixture.edit( + "src/lib.rs", + "pub fn graph_off_memory_beta() -> usize { 2 }\n", + ); + git( + fixture.path(), + &["commit", "-qam", "publish memory generation B"], + ); + scheduler.notify_path(fixture.path().join("src/lib.rs")); + + let tight_limit = NonZeroU64::new(1024 * 1024).expect("tight canonical limit"); + assert!( + tracedecay_code_index::parallelism::worker_reservation_bytes( + tracedecay_code_index::parallelism::indexing_workers(), + ) > tight_limit.get(), + "the fixture limit must deny the installed worker plan" + ); + let tight = Arc::new(ProcessResidentMemoryV1::new(tight_limit)); + scheduler.bind_resident_memory(Arc::clone(&tight)); + let denied = scheduler.reconcile_retained_text_generation(&metadata_a); + assert!( + matches!( + denied, + Err(super::CodeIndexSchedulerErrorV1::WorkerMemoryAdmission(_)) + ), + "graph-off capture must be refused by canonical worker admission: {denied:?}" + ); + assert_eq!( + scheduler + .publication + .read_publication_pointer() + .expect("read pointer after denial") + .expect("active pointer after denial"), + generation_a, + "denied capture must leave generation A durable" + ); + assert_eq!( + tight.snapshot().used_bytes, + 0, + "a denied worker reservation must not leak a charge" + ); + + let adequate = Arc::new(ProcessResidentMemoryV1::new( + DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, + )); + scheduler.bind_resident_memory(Arc::clone(&adequate)); + let outcome = scheduler + .reconcile_retained_text_generation(&metadata_a) + .expect("adequately funded graph-off retry") + .expect("graph-off retry outcome"); + let generation_b = published(outcome); + assert_ne!( + generation_b.generation_id.as_str(), + generation_a.generation_id + ); + assert_eq!( + scheduler + .publication + .read_publication_pointer() + .expect("read generation B pointer") + .expect("generation B pointer") + .generation_id, + generation_b.generation_id.as_str() + ); + assert_eq!(scheduler.sealed_decode_count(), 0); + assert!( + adequate.snapshot().charges.iter().all(|charge| { + charge.key.component.as_str() != super::CODE_INDEX_WORKER_RESIDENT_COMPONENT_V1 + }), + "the successful retry must release its worker reservation" + ); +} + /// Publishing a changed graph-off generation must hand text authority from the /// ready prior generation to the new durable pointer. Otherwise the worker's /// graph-only `latest` slot stays empty, neither serving swap runs, and queries @@ -10269,6 +10371,24 @@ async fn graph_off_changed_source_advances_text_authority_without_full_decode() "pub fn beta_0000() -> usize { 10_000 }\n", ); git(fixture.path(), &["commit", "-qam", "publish generation B"]); + let durable_generations_root = { + let mut scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let durable = scheduler.publication.generations_root.clone(); + let blocker = store.path().join("publication-root-blocker"); + std::fs::write(&blocker, b"not a directory").expect("write publication blocker"); + scheduler.publication.generations_root = blocker; + durable + }; + let reconcile_in_progress = { + Arc::clone( + &scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .reconcile_in_progress, + ) + }; assert!( registry .notify_hook_paths(fixture.path(), &["src/file_0000.rs".to_owned()]) @@ -10276,6 +10396,54 @@ async fn graph_off_changed_source_advances_text_authority_without_full_decode() "changed source wakes the mounted graph-off owner" ); + let attempt_deadline = std::time::Instant::now() + Duration::from_secs(10); + while reconcile_in_progress.load(std::sync::atomic::Ordering::Acquire) == 0 { + assert!( + std::time::Instant::now() <= attempt_deadline, + "transient publication failure was never attempted" + ); + tokio::task::yield_now().await; + } + let restore_deadline = std::time::Instant::now() + Duration::from_secs(10); + while reconcile_in_progress.load(std::sync::atomic::Ordering::Acquire) != 0 { + assert!( + std::time::Instant::now() <= restore_deadline, + "transient publication failure did not terminate" + ); + tokio::task::yield_now().await; + } + { + let mut scheduler = scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!( + !scheduler + .hints + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .paths + .is_empty(), + "transient publication failure lost the drained source hint" + ); + assert_eq!( + scheduler + .publication + .read_publication_pointer() + .expect("read pointer after transient failure") + .expect("generation A remains durable") + .generation_id, + generation_a.as_str() + ); + assert_eq!(scheduler.sealed_decode_count(), 0); + scheduler.publication.generations_root = durable_generations_root; + } + assert!( + registry + .notify_hook_paths(fixture.path(), &["src/file_0000.rs".to_owned()]) + .await, + "retry wake reaches the restored source hint" + ); + let publication_deadline = std::time::Instant::now() + Duration::from_secs(10); let generation_b = loop { let durable = { From 07c8e4be7da3141b95145f7d863555f2db3138b7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 10:12:55 +0000 Subject: [PATCH 51/69] perf(query): parallelize SQLite index sorting --- .../retrieval/lexical/projection/artifact.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs index 7a3f9198ae..da477f4d29 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs @@ -129,6 +129,14 @@ fn sqlite_corrupt(error: rusqlite::Error) -> CodeLexicalArtifactErrorV1 { /// `journal_mode = DELETE`: a sealed artifact is one content-addressed file, /// and a WAL sidecar would fall outside its digest; bounded finalization /// persists its own verified progress, so rollback-journal durability suffices. +/// SQLite's auxiliary sorter width reuses the canonical code-index worker +/// authority: the connection thread occupies one admitted worker and SQLite +/// may use only the remainder. `temp_store = FILE` keeps corpus-wide CREATE +/// INDEX runs disk-backed; their allocator/statement overhead remains outside +/// this module's narrowed memory-ledger claim. The modeled-reservation gauge +/// reports the caller plus effective helpers at the canonical 128 MiB worker +/// charge; it is a subset of the scheduler's existing admission, not another +/// cache or a second memory authority. fn open_builder_connection( path: &Path, ) -> Result { @@ -150,6 +158,34 @@ fn open_builder_connection( connection .pragma_update(None, "cache_size", cache_kib) .map_err(sqlite_error)?; + let requested_sorter_workers = + tracedecay_code_index::parallelism::indexing_workers().saturating_sub(1); + let requested_sorter_workers_i64 = i64::try_from(requested_sorter_workers) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + connection + .pragma_update(None, "threads", requested_sorter_workers_i64) + .map_err(sqlite_error)?; + let effective_sorter_workers: i64 = connection + .pragma_query_value(None, "threads", |row| row.get(0)) + .map_err(sqlite_error)?; + let effective_sorter_workers = usize::try_from(effective_sorter_workers).map_err(|_| { + CodeLexicalArtifactErrorV1::Contract( + "SQLite returned a negative lexical sorter worker limit".to_owned(), + ) + })?; + if effective_sorter_workers > requested_sorter_workers { + return Err(CodeLexicalArtifactErrorV1::Contract(format!( + "SQLite granted {effective_sorter_workers} lexical sorter workers above the canonical {requested_sorter_workers} auxiliary-worker bound" + ))); + } + hotpath::gauge!("query.artifact.sqlite_sorter_workers.requested").set(requested_sorter_workers); + hotpath::gauge!("query.artifact.sqlite_sorter_workers.effective").set(effective_sorter_workers); + hotpath::gauge!("query.artifact.sqlite_sorter.modeled_reservation_bytes").set( + tracedecay_code_index::parallelism::worker_reservation_bytes( + effective_sorter_workers.saturating_add(1), + ), + ); + hotpath::gauge!("query.artifact.sqlite_sorter.temp_store_file").set(1u64); Ok(connection) } @@ -191,5 +227,40 @@ mod tests { synchronous, 1, "artifact staging must use synchronous=NORMAL" ); + let temp_store: i64 = connection + .pragma_query_value(None, "temp_store", |row| row.get(0)) + .expect("temp-store pragma"); + assert_eq!( + temp_store, 1, + "SQLite sorter PMAs must spill to files rather than retaining the corpus in memory" + ); + } + + #[test] + fn builder_connections_reuse_canonical_worker_width_for_sqlite_sorters() { + let directory = tempfile::tempdir().expect("artifact tempdir"); + let connection = open_builder_connection(&directory.path().join("workers.sqlite")) + .expect("builder connection"); + let capability_probe = + rusqlite::Connection::open_in_memory().expect("open SQLite worker capability probe"); + capability_probe + .pragma_update(None, "threads", i64::MAX) + .expect("probe SQLite worker ceiling"); + let sqlite_worker_ceiling: i64 = capability_probe + .pragma_query_value(None, "threads", |row| row.get(0)) + .expect("read SQLite worker ceiling"); + let admitted_auxiliary_threads = tracedecay_code_index::parallelism::indexing_workers() + .saturating_sub(1) + .min( + usize::try_from(sqlite_worker_ceiling).expect("nonnegative SQLite worker ceiling"), + ); + let configured_threads: i64 = connection + .pragma_query_value(None, "threads", |row| row.get(0)) + .expect("read artifact SQLite worker limit"); + assert_eq!( + usize::try_from(configured_threads).expect("nonnegative artifact worker limit"), + admitted_auxiliary_threads, + "SQLite must receive the maximum auxiliary width available below the canonical worker bound and its own compile-time ceiling" + ); } } From bbf3259e716362e9aa460ba95af051713655c918 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 15:52:45 +0000 Subject: [PATCH 52/69] fix(index): serve graph-off queries from text artifacts --- src/daemon/code_index_scheduler.rs | 110 ++++++-- src/daemon/code_index_scheduler/queries.rs | 288 ++++++++++++++++++-- src/daemon/code_index_scheduler/registry.rs | 72 ++++- src/daemon/code_index_scheduler/tests.rs | 110 +++++++- 4 files changed, 534 insertions(+), 46 deletions(-) diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 7f88ba097e..06325f9853 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -1960,6 +1960,7 @@ pub(super) struct ProductionCodeIndexQueryOwnersV1 { CodeExactLexicalArtifactReaderV1, >, lexical: LexicalLane, + hydration: CodeLexicalArtifactReaderV1, /// Holds the complete advertised reader ceiling in the process resident- /// memory authority while these owners serve. _reader_reservation: Arc, @@ -1979,15 +1980,65 @@ impl ProductionCodeIndexQueryOwnersV1 { CodeExactLexicalArtifactReaderV1, >, lexical: LexicalLane, + hydration: CodeLexicalArtifactReaderV1, reader_reservation: ResidentMemoryReservationV1, ) -> Self { Self { exact, lexical, + hydration, _reader_reservation: Arc::new(reader_reservation), } } + fn occurrence_by_binding( + &self, + binding: &tracedecay_query::retrieval::ports::CodeCandidateBindingV1, + ) -> Result< + tracedecay_query::retrieval::NativeCodeOccurrenceV1, + tracedecay_query::retrieval::QueryExecutionContractErrorV1, + > { + self.hydration + .occurrence_by_binding(binding) + .map_err(|_| { + tracedecay_query::retrieval::QueryExecutionContractErrorV1::RecordUnavailable + })? + .map( + |occurrence| tracedecay_query::retrieval::NativeCodeOccurrenceV1 { + file: occurrence.file, + symbol: occurrence.symbol, + chunk: Some(occurrence.chunk), + path: occurrence.logical_path, + span: occurrence.source_span, + }, + ) + .ok_or(tracedecay_query::retrieval::QueryExecutionContractErrorV1::RecordUnavailable) + } + + fn occurrence_by_chunk( + &self, + chunk: &tracedecay_domain::CodeSearchChunkId, + ) -> Result< + tracedecay_query::retrieval::NativeCodeOccurrenceV1, + tracedecay_query::retrieval::QueryExecutionContractErrorV1, + > { + self.hydration + .occurrence_by_chunk(chunk) + .map_err(|_| { + tracedecay_query::retrieval::QueryExecutionContractErrorV1::RecordUnavailable + })? + .map( + |occurrence| tracedecay_query::retrieval::NativeCodeOccurrenceV1 { + file: occurrence.file, + symbol: occurrence.symbol, + chunk: Some(occurrence.chunk), + path: occurrence.logical_path, + span: occurrence.source_span, + }, + ) + .ok_or(tracedecay_query::retrieval::QueryExecutionContractErrorV1::RecordUnavailable) + } + pub fn retrieve_lexical( &self, request: &LexicalLaneRequest<'_>, @@ -3477,10 +3528,12 @@ impl LatestCodeTextGenerationV1 { } let authority = exact_serving_authority()?; let exact = ExactLane::new(authority.clone(), reader.exact_adapter(authority)); + let hydration = reader.clone(); let lexical = LexicalLane::new(reader); let owners = Arc::new(ProductionCodeIndexQueryOwnersV1::artifact( exact, lexical, + hydration, reader_reservation, )); let _ = self.query_owners.set(owners); @@ -4123,12 +4176,11 @@ impl CodeIndexWorktreeSchedulerV1 { { return Ok(None); } - let Some(witness) = RestoreFreshnessWitnessV1::load(&self.store_root) else { - return Ok(None); - }; - if witness.generation_id != metadata.manifest().generation_id.as_str() - || !witness.ignored_source_paths.is_empty() - || !self.ignored_source_admissions.is_empty() + let witness = RestoreFreshnessWitnessV1::load(&self.store_root); + if witness.as_ref().is_some_and(|witness| { + witness.generation_id != metadata.manifest().generation_id.as_str() + || !witness.ignored_source_paths.is_empty() + }) || !self.ignored_source_admissions.is_empty() { return Ok(None); } @@ -4151,7 +4203,7 @@ impl CodeIndexWorktreeSchedulerV1 { .unwrap_or_else(std::sync::PoisonError::into_inner); hints.overflow || !hints.paths.is_empty() }; - if !has_hints { + if !has_hints && let Some(witness) = witness.as_ref() { if witness.git_metadata_signature != sampled_metadata.stable_signature() || witness.stat_signature != sampled_signature { @@ -4186,6 +4238,15 @@ impl CodeIndexWorktreeSchedulerV1 { || captured.snapshot.source_revision != metadata.snapshot().source_revision || captured.snapshot.content_identity != metadata.snapshot().content_identity { + if witness.is_none() { + // Without the durable ignored-source roster witness, an + // unequal capture cannot distinguish a real edit from a file + // the sealed generation intentionally excluded. Fall back to + // the complete authority for that changed-source case. An + // equal authoritative capture is sufficient to verify the + // retained text generation without decoding it. + return Ok(None); + } let pointer = self .publication .read_publication_pointer() @@ -4290,15 +4351,17 @@ impl CodeIndexWorktreeSchedulerV1 { let snapshot_content_identity = captured.snapshot.content_identity; self.latest_content_identity = Some(snapshot_content_identity.clone()); self.mark_reconciled_state(sampled_metadata.clone(), Some(sampled_signature.clone())); - RestoreFreshnessWitnessV1 { - generation_id: witness.generation_id, - git_metadata_signature: sampled_metadata.stable_signature(), - stat_signature: sampled_signature, - repository_parse_identity_digest: witness.repository_parse_identity_digest, - ignored_source_admissions_digest: witness.ignored_source_admissions_digest, - ignored_source_paths: witness.ignored_source_paths, - } - .persist(&self.store_root); + if let Some(witness) = witness { + RestoreFreshnessWitnessV1 { + generation_id: witness.generation_id, + git_metadata_signature: sampled_metadata.stable_signature(), + stat_signature: sampled_signature, + repository_parse_identity_digest: witness.repository_parse_identity_digest, + ignored_source_admissions_digest: witness.ignored_source_admissions_digest, + ignored_source_paths: witness.ignored_source_paths, + } + .persist(&self.store_root); + } let outcome = CodeIndexReconcileOutcomeV1::Noop(CodeIndexNoopEvidenceV1 { snapshot_content_identity, overflow_reconciled: drained_hints.overflow(), @@ -4893,12 +4956,23 @@ impl CodeIndexWorktreeSchedulerV1 { if !self.verified_against_source || identity::GitMetadataFingerprintV1::capture(&self.project_root) .differs_from(&self.git_metadata) - || self.last_reconciled_at.elapsed() >= self.policy.staleness_threshold { self.request_background_reconcile(); return true; } - false + if self.last_reconciled_at.elapsed() < self.policy.staleness_threshold { + return false; + } + if self + .worktree_stat_signature() + .is_ok_and(|signature| self.last_stat_signature.as_ref() == Some(&signature)) + { + self.last_reconciled_at = Instant::now(); + self.last_reconciled_at_micros = Some(now_micros().0); + return false; + } + self.request_background_reconcile(); + true } /// The exact identity this scheduler is currently bound to. diff --git a/src/daemon/code_index_scheduler/queries.rs b/src/daemon/code_index_scheduler/queries.rs index 754f4c81b5..eab2f476ac 100644 --- a/src/daemon/code_index_scheduler/queries.rs +++ b/src/daemon/code_index_scheduler/queries.rs @@ -39,7 +39,8 @@ use tracedecay_domain::{ use tracedecay_tool_catalog::SortContractId; use super::{ - CodeIndexSchedulerRegistryV1, DaemonCodeIndexPublicationStoreV1, LatestCompleteCodeIndexV1, + CodeIndexSchedulerRegistryV1, DaemonCodeIndexPublicationStoreV1, LatestCodeTextGenerationV1, + LatestCompleteCodeIndexV1, ProductionCodeIndexQueryOwnersV1, registry::{UniqueMountedWorktree, latest_matches_scope_identity, unique_mounted_for_scope}, }; use tracedecay_query::code_search; @@ -376,6 +377,78 @@ impl CodeIndexSchedulerRegistryV1 { } Ok(latest) } + + async fn resolve_text_serving_generation( + &self, + request: &RequestContext, + requested: &CodeGenerationId, + page: &tracedecay_application::PageRequest, + authority: &tracedecay_query::retrieval::QueryAuthorityV1, + routing: &PreparedQueryRoutingBindingsV1, + ) -> Result { + let wait = remaining_generation_resolution_wait(request) + .ok_or(CallableCodeCursorError::Unavailable)?; + let resolution = async { + if let Some(cursor) = page.cursor.as_ref() { + let expected_generation = (!is_unpinned_latest(requested)).then_some(requested); + let scope = request.scope().clone(); + route_authenticated_prepared_query_cursor( + authority, + routing, + cursor.as_str(), + current_utc_micros()?, + expected_generation, + |generation| async move { + if let Some(latest) = self + .latest_text_serving_for_scope(&scope) + .await + .filter(|latest| { + latest.metadata().manifest().generation_id == generation + }) + { + return Ok::<_, code_search::CodeIndexSearchUnavailableReasonV1>(Some( + latest, + )); + } + self.generation_for(&scope, &generation) + .await + .map(|latest| latest.map(|latest| latest.text_generation_handle())) + }, + )? + .await + .map_err(|_| CallableCodeCursorError::Unavailable)? + .ok_or(CallableCodeCursorError::Unavailable) + } else if is_unpinned_latest(requested) { + self.latest_text_fresh_for_scope(request.scope()) + .await + .ok_or(CallableCodeCursorError::Unavailable) + } else { + if let Some(latest) = self + .latest_text_serving_for_scope(request.scope()) + .await + .filter(|latest| latest.metadata().manifest().generation_id == *requested) + { + Ok(latest) + } else { + self.generation_for(request.scope(), requested) + .await + .map_err(|_| CallableCodeCursorError::Unavailable)? + .map(|latest| latest.text_generation_handle()) + .ok_or(CallableCodeCursorError::Unavailable) + } + } + }; + let latest = tokio::time::timeout(wait, resolution) + .await + .map_err(|_| CallableCodeCursorError::Unavailable)??; + if !matches!( + request.admission_at(current_utc_micros()?), + RequestAdmission::Admitted + ) { + return Err(CallableCodeCursorError::Unavailable); + } + Ok(latest) + } } fn typed(value: impl Into) -> Result @@ -480,6 +553,41 @@ fn base_request( }) } +fn text_base_request( + context: &RetrievalPortContext<'_>, + latest: &LatestCodeTextGenerationV1, + temporal_mode: TemporalModeV1, + profile: &tracedecay_domain::FusionProfile, +) -> Result { + let manifest = latest.metadata().manifest(); + let snapshot = latest.metadata().snapshot(); + Ok(RetrievalRequest { + principal: typed::(context.request.actor().to_string())?, + scope: RetrievalScope { + privacy_domain: manifest.privacy_domain.clone(), + root: SingleRootScopeV1 { + repository: snapshot.repository.clone(), + worktree: snapshot.worktree.clone(), + reference: snapshot.reference.clone(), + }, + }, + temporal_mode, + snapshot: RetrievalSnapshot { + watermarks: VectorWatermark::default(), + freshness_digest: FreshnessVectorDigest::new(manifest.snapshot_digest.as_str()) + .map_err(|error| error.to_string())?, + authorization_revision: AuthorizationRevision::new(format!( + "authorization.grant.{}", + context.request.grant().revision + )) + .map_err(|error| error.to_string())?, + captured_at: manifest.seal.sealed_at, + }, + profile_id: profile.profile_id.clone(), + budget: profile.retrieval_budget, + }) +} + fn unavailable(finished_at: tracedecay_domain::UtcMicros) -> RetrievalPortOutcome { RetrievalPortOutcome::Unavailable(RetrievalEvidence { payload: None, @@ -996,6 +1104,42 @@ impl NativeRecordReadPortV1 for LatestCompleteNativeRecordReadPortV1<'_> { } } +struct TextArtifactNativeRecordReadPortV1 { + generation: CodeGenerationId, + owners: std::sync::Arc, +} + +impl NativeRecordReadPortV1 for TextArtifactNativeRecordReadPortV1 { + fn generation(&self) -> &CodeGenerationId { + &self.generation + } + + fn occurrence( + &self, + binding: &CodeCandidateBindingV1, + ) -> Result { + if &binding.occurrence.generation != self.generation() { + return Err(QueryExecutionContractErrorV1::GenerationMismatch); + } + self.owners.occurrence_by_binding(binding) + } + + fn occurrence_by_chunk( + &self, + chunk_id: &CodeSearchChunkId, + ) -> Result { + self.owners.occurrence_by_chunk(chunk_id) + } + + fn symbol( + &self, + _symbol: &SymbolOccurrenceId, + _file: &FileOccurrenceId, + ) -> Result { + Err(QueryExecutionContractErrorV1::RecordUnavailable) + } +} + fn application_occurrence(record: NativeCodeOccurrenceV1) -> CodeOccurrenceRecord { CodeOccurrenceRecord { file: record.file, @@ -1110,6 +1254,36 @@ struct PreparedCallableQueryV1 { query: PreparedQueryV1, } +struct PreparedTextCallableQueryV1 { + latest: LatestCodeTextGenerationV1, + query: PreparedQueryV1, +} + +trait PreparedCallableQueryStateV1 { + fn generation(&self) -> &CodeGenerationId; + fn query(&self) -> &PreparedQueryV1; +} + +impl PreparedCallableQueryStateV1 for PreparedCallableQueryV1 { + fn generation(&self) -> &CodeGenerationId { + &self.latest.generation.manifest().generation_id + } + + fn query(&self) -> &PreparedQueryV1 { + &self.query + } +} + +impl PreparedCallableQueryStateV1 for PreparedTextCallableQueryV1 { + fn generation(&self) -> &CodeGenerationId { + &self.latest.metadata().manifest().generation_id + } + + fn query(&self) -> &PreparedQueryV1 { + &self.query + } +} + macro_rules! prepare_callable_query_or_return { ($registry:expr, $context:expr, $request:expr, $operation:expr, $binding:expr) => {{ let Ok(query_binding_digest) = canonical_sha256(&$binding) else { @@ -1138,6 +1312,34 @@ macro_rules! prepare_callable_query_or_return { }}; } +macro_rules! prepare_text_callable_query_or_return { + ($registry:expr, $context:expr, $request:expr, $operation:expr, $binding:expr) => {{ + let Ok(query_binding_digest) = canonical_sha256(&$binding) else { + return unavailable(query_finished_at()); + }; + match $registry + .prepare_text_callable_query( + &$context, + &$request.scope.generation, + &$request.meta.page, + $request.meta.temporal, + $operation, + query_binding_digest.clone(), + ) + .await + { + Ok(prepared) => (prepared, query_binding_digest), + Err(error) => { + return rejected_cursor( + query_finished_at(), + $request.scope.generation.clone(), + error, + ); + } + } + }}; +} + /// Resolves the traversal start symbol a relation query is anchored on. /// /// A node id that is not a symbol occurrence is unavailable outright; one that @@ -1198,6 +1400,45 @@ impl CodeIndexSchedulerRegistryV1 { )?; Ok(PreparedCallableQueryV1 { latest, query }) } + + async fn prepare_text_callable_query( + &self, + context: &RetrievalPortContext<'_>, + generation: &CodeGenerationId, + page: &tracedecay_application::PageRequest, + temporal: TemporalModeV1, + operation: &'static str, + query_binding_digest: ManifestDigest, + ) -> Result { + let authority = self + .query_authority_for_scope(context.request.scope()) + .await + .ok_or(CallableCodeCursorError::Unavailable)?; + let routing = prepared_routing_bindings( + context, + temporal, + operation, + query_binding_digest, + page.page_size, + )?; + let latest = self + .resolve_text_serving_generation( + context.request, + generation, + page, + authority.as_ref(), + &routing, + ) + .await?; + let base = text_base_request(context, &latest, temporal, authority.profile()) + .map_err(|_| CallableCodeCursorError::Unavailable)?; + let query = PreparedQueryV1::prepare( + authority, + base, + page.cursor.as_ref().map(OpaqueCursor::as_str), + )?; + Ok(PreparedTextCallableQueryV1 { latest, query }) + } } #[allow(clippy::too_many_arguments)] @@ -1270,7 +1511,7 @@ fn finish_generation_page( #[allow(clippy::too_many_arguments)] fn finish_query_with_coverage( - prepared: &PreparedCallableQueryV1, + prepared: &impl PreparedCallableQueryStateV1, context: &RetrievalPortContext<'_>, operation: &'static str, query_binding_digest: ManifestDigest, @@ -1279,7 +1520,7 @@ fn finish_query_with_coverage( coverage: tracedecay_domain::RetrieverCoverage, ) -> RetrievalPortOutcome> { let finished_at = query_finished_at(); - let generation = prepared.latest.generation.manifest().generation_id.clone(); + let generation = prepared.generation().clone(); let bindings = PreparedQueryBindingsV1::new( operation, context.request.scope().scope_digest.clone(), @@ -1288,7 +1529,7 @@ fn finish_query_with_coverage( ); let pagination = bindings.and_then(|bindings| { prepared - .query + .query() .paginate(&bindings, page.items, requested_page.page_size, finished_at) }); match pagination { @@ -1426,7 +1667,7 @@ fn terminal_lane_evidence( #[allow(clippy::too_many_arguments)] fn finish_native_lane_page( - prepared: &PreparedCallableQueryV1, + prepared: &impl PreparedCallableQueryStateV1, context: &RetrievalPortContext<'_>, operation: &'static str, query_binding_digest: ManifestDigest, @@ -1495,7 +1736,7 @@ fn mark_lane_partial( #[allow(clippy::too_many_arguments)] fn finish_native_lane_query( - prepared: &PreparedCallableQueryV1, + prepared: &impl PreparedCallableQueryStateV1, context: &RetrievalPortContext<'_>, operation: &'static str, query_binding_digest: ManifestDigest, @@ -1533,11 +1774,8 @@ where } NativeLaneOutcomeV1::Unavailable(reason) => { let omission = retrieval_failure_omission(&reason); - let evidence = terminal_lane_evidence( - finished_at, - prepared.latest.generation.manifest().generation_id.clone(), - omission, - ); + let evidence = + terminal_lane_evidence(finished_at, prepared.generation().clone(), omission); match reason { RetrievalFailure::InvalidRequest { .. } | RetrievalFailure::Internal { .. } => { RetrievalPortOutcome::Failed(evidence) @@ -1549,18 +1787,18 @@ where } NativeLaneOutcomeV1::Denied => RetrievalPortOutcome::Unavailable(terminal_lane_evidence( finished_at, - prepared.latest.generation.manifest().generation_id.clone(), + prepared.generation().clone(), OmissionReason::Unavailable, )), NativeLaneOutcomeV1::Stale(_) => RetrievalPortOutcome::Unavailable(terminal_lane_evidence( finished_at, - prepared.latest.generation.manifest().generation_id.clone(), + prepared.generation().clone(), OmissionReason::Stale, )), NativeLaneOutcomeV1::BudgetExceeded(usage) => { let mut evidence = terminal_lane_evidence( finished_at, - prepared.latest.generation.manifest().generation_id.clone(), + prepared.generation().clone(), OmissionReason::Budget, ); evidence.budget = application_budget_usage(usage); @@ -1569,7 +1807,7 @@ where NativeLaneOutcomeV1::TimedOut(usage) => { let mut evidence = terminal_lane_evidence( finished_at, - prepared.latest.generation.manifest().generation_id.clone(), + prepared.generation().clone(), OmissionReason::TimedOut, ); evidence.budget = application_budget_usage(usage); @@ -1578,7 +1816,7 @@ where NativeLaneOutcomeV1::Cancelled => { let mut evidence = terminal_lane_evidence( finished_at, - prepared.latest.generation.manifest().generation_id.clone(), + prepared.generation().clone(), OmissionReason::Cancelled, ); evidence.cancellation = Some(CancellationObservation { @@ -1610,7 +1848,7 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { request: &'a ExactOccurrenceRequest, ) -> CallableCodeQueryFuture<'a, ExactOccurrenceRecord> { Box::pin(async move { - let (prepared, query_binding_digest) = prepare_callable_query_or_return!( + let (prepared, query_binding_digest) = prepare_text_callable_query_or_return!( self, context, request, @@ -1625,7 +1863,7 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { ) ); let latest = &prepared.latest; - let served_generation = latest.generation.manifest().generation_id.clone(); + let served_generation = latest.metadata().manifest().generation_id.clone(); let finished_at = query_finished_at(); let base = prepared.query.request(); let Ok(query_view) = tracedecay_domain::EphemeralSanitizedQueryViewV1::sanitize( @@ -1651,7 +1889,10 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { let Ok(owners) = latest.production_query_owners_with_budget(&base.budget) else { return unavailable(finished_at); }; - let records = LatestCompleteNativeRecordReadPortV1 { latest }; + let records = TextArtifactNativeRecordReadPortV1 { + generation: served_generation.clone(), + owners: std::sync::Arc::clone(&owners), + }; let Ok(native_context) = AdmittedGenerationContextV1::admit(served_generation.clone(), &records) else { @@ -1688,7 +1929,7 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { request: &'a PhraseSearchRequest, ) -> CallableCodeQueryFuture<'a, LexicalOccurrenceRecord> { Box::pin(async move { - let (prepared, query_binding_digest) = prepare_callable_query_or_return!( + let (prepared, query_binding_digest) = prepare_text_callable_query_or_return!( self, context, request, @@ -1705,7 +1946,7 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { ) ); let latest = &prepared.latest; - let served_generation = latest.generation.manifest().generation_id.clone(); + let served_generation = latest.metadata().manifest().generation_id.clone(); let finished_at = query_finished_at(); let base = prepared.query.request(); let whole_terms = request @@ -1754,7 +1995,10 @@ impl CallableCodeQueryPort for CodeIndexSchedulerRegistryV1 { let Ok(owners) = latest.production_query_owners_with_budget(&base.budget) else { return unavailable(finished_at); }; - let records = LatestCompleteNativeRecordReadPortV1 { latest }; + let records = TextArtifactNativeRecordReadPortV1 { + generation: served_generation.clone(), + owners: std::sync::Arc::clone(&owners), + }; let Ok(native_context) = AdmittedGenerationContextV1::admit(served_generation.clone(), &records) else { diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index efb72c187a..8994659639 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -3544,6 +3544,9 @@ impl CodeIndexSchedulerRegistryV1 { .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + let text_ready = text + .as_ref() + .is_some_and(LatestCodeTextGenerationV1::text_serving_is_ready); let identity = if latest.is_some() { dashboard_freshness_identity(latest.as_ref()) } else { @@ -3553,7 +3556,7 @@ impl CodeIndexSchedulerRegistryV1 { worktree_root: canonical_root.display().to_string(), last_reconcile_micros: None, staleness_state: Some( - if latest.is_some() { + if latest.is_some() || text_ready { "refreshing" } else { "indexing" @@ -3577,20 +3580,23 @@ impl CodeIndexSchedulerRegistryV1 { .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); + let text_ready = text + .as_ref() + .is_some_and(LatestCodeTextGenerationV1::text_serving_is_ready); let hook_hint_count = scheduler.pending_hint_count(); let staleness_state = if refreshing { - if latest.is_some() { + if latest.is_some() || text_ready { "refreshing" } else { "indexing" } } else if stale || hook_hint_count != Some(0) { - if latest.is_some() { + if latest.is_some() || text_ready { "stale" } else { "indexing" } - } else if latest.is_some() { + } else if latest.is_some() || text_ready { "fresh" } else { "indexing" @@ -4016,6 +4022,64 @@ impl CodeIndexSchedulerRegistryV1 { .then_some(latest) } + /// Resolve graph-independent exact/lexical serving through the same cheap + /// freshness ladder as complete-generation queries. The immutable text + /// owner remains servable while a real edit is reconciled in the retained + /// background worker; a quiet repository only refreshes its stat witness + /// clock and posts no wake. + pub(in crate::daemon) async fn latest_text_fresh_for_scope( + &self, + scope: &tracedecay_application::ResolvedScope, + ) -> Option { + let (root, scheduler, text_generation, wake, pending_wake) = { + let mounted = self.mounted.lock().await; + let (root, worktree) = unique_mounted_for_scope(&mounted, scope).unique()?; + ( + root.clone(), + Arc::clone(&worktree.scheduler), + Arc::clone(&worktree.text_generation), + Arc::clone(&worktree.wake), + Arc::clone(&worktree.pending_wake), + ) + }; + let scope = scope.clone(); + tokio::task::spawn_blocking(move || { + if gix::open(&root).is_err() { + return None; + } + let latest = text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .filter(|latest| { + latest.text_serving_is_ready() && text_matches_scope_identity(latest, &scope) + })?; + let mut scheduler = match scheduler.try_lock() { + Ok(scheduler) => scheduler, + Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => { + Self::note_wake( + &pending_wake, + &wake, + CodeIndexCadenceTriggerV1::BusyFollowUp, + ); + return Some(latest); + } + }; + if scheduler.request_fresh_for_query_background() { + Self::note_wake( + &pending_wake, + &wake, + CodeIndexCadenceTriggerV1::QueryAdmission, + ); + } + Some(latest) + }) + .await + .ok() + .flatten() + } + pub(in crate::daemon) async fn latest_complete_serving_for_scope( &self, scope: &tracedecay_application::ResolvedScope, diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 1860367734..88c0f2bd97 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -9914,6 +9914,11 @@ async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() latest.generation.manifest().privacy_domain.clone(), ) }; + std::fs::remove_file( + super::scoped_code_index_store_root(store.path(), fixture.path()) + .join("freshness_witness.v1"), + ) + .expect("remove restore witness to reproduce a cold preserved-profile mount"); let registry = CodeIndexSchedulerRegistryV1::with_background_reconcile_permits(1, 1); registry @@ -9970,12 +9975,16 @@ async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() .acquire_owned() .await .expect("pause text projection after a bounded committed slice"); - let last_reconciled_before_status_polls = { + let (last_reconciled_before_status_polls, original_staleness_threshold) = { let mut scheduler = scheduler .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let original_staleness_threshold = scheduler.policy.staleness_threshold; scheduler.policy.staleness_threshold = Duration::ZERO; - scheduler.last_reconciled_at_micros() + ( + scheduler.last_reconciled_at_micros(), + original_staleness_threshold, + ) }; for _ in 0..8 { assert!( @@ -10021,6 +10030,11 @@ async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() assert!(progress.progress_epoch >= progress_before_overflow.progress_epoch); assert!(progress.committed_pages >= progress_before_overflow.committed_pages); } + scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .policy + .staleness_threshold = original_staleness_threshold; drop(status_poll_admission); let last_reconciled_before_overflow = scheduler .lock() @@ -10156,6 +10170,98 @@ async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() .get(&RetrieverKind::Graph), Some(&PublicRetrieverStatus::Unavailable) ); + + let dashboard = registry + .dashboard_freshness(fixture.path()) + .await + .expect("graph-off dashboard freshness"); + assert_eq!(dashboard.staleness_state.as_deref(), Some("fresh")); + assert_eq!(dashboard.coverage, "complete"); + assert_eq!( + dashboard + .progress + .as_ref() + .expect("ready progress stays observable") + .phase, + crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready + ); + + let query_scope = CodeQueryScope::new(executed.generation.clone(), None).expect("query scope"); + let exact_operation = + callable_code_operation(CallableCodeOperationKind::ExactOccurrence).expect("operation"); + let exact_context = application_context( + &exact_operation, + scope.repository_id.clone(), + scope.worktree_id.clone(), + ); + let exact_request = + ExactOccurrenceRequest::new("alpha_0000", None, query_scope.clone(), query_meta()) + .expect("exact request"); + let exact = registry + .exact_occurrence( + RetrievalPortContext { + request: &exact_context, + operation: &exact_operation, + }, + &exact_request, + ) + .await; + let exact_page = match exact { + RetrievalPortOutcome::Completed(evidence) => evidence.payload.expect("exact payload"), + outcome => panic!("ready graph-off exact owner was unavailable: {outcome:?}"), + }; + assert_eq!(exact_page.generation, executed.generation); + assert!( + exact_page + .items + .iter() + .any(|record| record.occurrence.path == "src/file_0000.rs"), + "artifact-backed exact hydration returns the canonical source path" + ); + + let phrase_operation = + callable_code_operation(CallableCodeOperationKind::PhraseSearch).expect("operation"); + let phrase_context = application_context( + &phrase_operation, + scope.repository_id.clone(), + scope.worktree_id.clone(), + ); + let query = EphemeralSanitizedQueryViewV1::sanitize( + "alpha_0000", + SanitizerRevision::new("sanitizer.query.fixture").expect("sanitizer"), + QueryNormalizationRevision::new("normalization.query.fixture").expect("normalization"), + ) + .expect("query"); + let phrase_request = PhraseSearchRequest::new( + query, + vec!["alpha_0000".to_owned()], + Vec::new(), + 0, + query_scope, + query_meta(), + ) + .expect("phrase request"); + let phrase = registry + .phrase_search( + RetrievalPortContext { + request: &phrase_context, + operation: &phrase_operation, + }, + &phrase_request, + ) + .await; + let phrase_page = match phrase { + RetrievalPortOutcome::Completed(evidence) => evidence.payload.expect("phrase payload"), + outcome => panic!("ready graph-off phrase owner was unavailable: {outcome:?}"), + }; + assert_eq!(phrase_page.generation, executed.generation); + assert!( + phrase_page + .items + .iter() + .any(|record| record.occurrence.path == "src/file_0000.rs"), + "artifact-backed lexical hydration returns the canonical source path" + ); registry.shutdown().await; } From c731629b48f59fdebfae75e49c87fb2bdd256256 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 16:15:14 +0000 Subject: [PATCH 53/69] perf(query): widen bounded lexical catch-up --- crates/tracedecay-query/Cargo.toml | 1 + .../retrieval/lexical/projection/artifact.rs | 64 ++++++++++++++++++- .../lexical/projection/artifact/builder.rs | 4 +- .../candidate_producers.rs | 31 +++++++++ src/daemon/code_index_scheduler.rs | 10 ++- 5 files changed, 105 insertions(+), 5 deletions(-) diff --git a/crates/tracedecay-query/Cargo.toml b/crates/tracedecay-query/Cargo.toml index 42db0beafc..cdfcb345d6 100644 --- a/crates/tracedecay-query/Cargo.toml +++ b/crates/tracedecay-query/Cargo.toml @@ -76,6 +76,7 @@ tempfile = "3" tracedecay-code-extraction = { path = "../tracedecay-code-extraction", features = ["lite"] } tracedecay-code-index = { path = "../tracedecay-code-index", default-features = false, features = ["lite", "test-helpers"] } tracedecay-graph-db = { path = "../tracedecay-graph-db", version = "0.1.0", features = ["test-helpers"] } +tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0" } [[bench]] name = "code_lexical_catchup" diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs index da477f4d29..4ebf34d0b2 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs @@ -41,7 +41,7 @@ pub use reader::{CodeExactLexicalArtifactReaderV1, CodeLexicalArtifactReaderV1}; /// is a target the engine may transiently exceed, per-statement and /// allocator metadata overhead are unaccounted, and `temp_store = FILE` /// keeps temporary b-trees on disk rather than bounding them in memory. -pub const CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1: usize = 256 * 1024 * 1024; +pub const CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1: usize = 768 * 1024 * 1024; /// Maximum reader cache budget: the stored metadata copy plus the SQLite /// page-cache grant, which stays inside the kernel SQLite window ([2, 64] /// MiB page cache, mmap disabled). The reader's retained claim is the @@ -189,9 +189,36 @@ fn open_builder_connection( Ok(connection) } +fn with_builder_sorter_cpu_admission( + connection: &rusqlite::Connection, + operation: impl FnOnce() -> T, +) -> Result { + let effective_sorter_workers: i64 = connection + .pragma_query_value(None, "threads", |row| row.get(0)) + .map_err(sqlite_error)?; + let admitted_units = usize::try_from(effective_sorter_workers) + .map_err(|_| { + CodeLexicalArtifactErrorV1::Contract( + "SQLite returned a negative lexical sorter worker limit".to_owned(), + ) + })? + .checked_add(1) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical sorter CPU admission width overflowed".to_owned(), + ) + })?; + hotpath::gauge!("query.artifact.sqlite_sorter.admitted_cpu_units").set(admitted_units); + Ok(tracedecay_code_index::parallelism::with_background_cpu_permits(admitted_units, operation)) +} + #[cfg(test)] mod tests { - use super::{ARTIFACT_SQLITE_CACHE_BYTES, open_builder_connection}; + use std::num::NonZeroUsize; + + use super::{ + ARTIFACT_SQLITE_CACHE_BYTES, open_builder_connection, with_builder_sorter_cpu_admission, + }; /// Artifact connections stay inside the kernel SQLite window: no mmap /// grant, page cache at most 64 MiB, and `synchronous = NORMAL` — never @@ -263,4 +290,37 @@ mod tests { "SQLite must receive the maximum auxiliary width available below the canonical worker bound and its own compile-time ceiling" ); } + + #[test] + fn builder_sorter_statements_hold_their_weighted_cpu_width() { + let worker_width = tracedecay_code_index::parallelism::indexing_workers(); + let authority = tracedecay_runtime_core::background_cpu::install_process_background_cpu( + NonZeroUsize::new(worker_width).expect("nonzero code-index worker width"), + ) + .expect("install matching process background CPU authority"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let connection = open_builder_connection(&directory.path().join("weighted.sqlite")) + .expect("builder connection"); + let configured_threads: i64 = connection + .pragma_query_value(None, "threads", |row| row.get(0)) + .expect("read configured SQLite helper width"); + let expected_units = usize::try_from(configured_threads) + .expect("nonnegative SQLite helper width") + .saturating_add(1) + .min(worker_width); + + let observed_units = + with_builder_sorter_cpu_admission(&connection, || authority.active_units()) + .expect("run weighted SQLite statement"); + + assert_eq!( + observed_units, expected_units, + "one builder plus every configured SQLite helper must share the process CPU authority" + ); + assert_eq!( + authority.active_units(), + 0, + "weighted admission must release every unit after the statement" + ); + } } diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index 6d2a3d0be8..55ccdf973d 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -1126,7 +1126,9 @@ impl CodeLexicalArtifactBuilderV1 { )); } if state.phase != PersistedFinalizationPhaseV1::Digest { - advance_pre_digest_work(&transaction, &mut state, control)?; + super::with_builder_sorter_cpu_admission(&transaction, || { + advance_pre_digest_work(&transaction, &mut state, control) + })??; store_finalization_state(&transaction, &state)?; checkpoint(control)?; commit_finalization_transaction(transaction, &mut transaction_metrics)?; 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 a025fde822..483aa2a611 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -1640,6 +1640,37 @@ fn disk_artifact_admission_keeps_real_pages_wide_until_the_actual_limit() { assert_eq!(progress.next_page_ordinal, 2); } +#[test] +fn disk_artifact_large_build_authority_commits_thirty_two_real_pages() { + let fixture = real_lexical_source_fixture_with_files(32); + let (pages, _) = drain_verified_pages(&fixture, 1); + assert!( + pages.len() >= 32, + "the parser-backed corpus must expose a full 32-page source window" + ); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("thirty-two-page-batch.sqlite"); + let mut builder = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &artifact_path, + fixture.metadata, + 768 * 1024 * 1024, + ) + .expect("create builder with the production large-batch authority"); + let control = ArtifactControl { cancelled: false }; + let prepared = builder + .prepare_admissible_page_prefix(&pages[..32], &control) + .expect("prepare one full production source window"); + assert_eq!( + prepared.accepted_prefix().get(), + 32, + "the tracked memory, row, and write authorities must admit the complete real prefix" + ); + let progress = builder + .append_prepared_pages(prepared.prepared_pages(), &control) + .expect("commit the complete real prefix atomically"); + assert_eq!(progress.next_page_ordinal, 32); +} + #[test] fn disk_artifact_repetitive_multi_chunk_page_makes_exact_prefix_progress() { let mut source = String::with_capacity(700_000); diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 06325f9853..38433bdd07 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -115,8 +115,8 @@ const DURABLE_GENERATION_IO_CHUNK_BYTES_V1: usize = 64 * 1024; /// text artifact. One page is one bounded unit of background build progress. const TEXT_ARTIFACT_PAGE_CHUNKS_V1: usize = 128; const TEXT_ARTIFACT_PAGE_BYTES_V1: usize = 4 * 1024 * 1024; -const TEXT_ARTIFACT_BATCH_PAGES_V1: usize = 16; -const TEXT_ARTIFACT_BATCH_BYTES_V1: usize = 32 * 1024 * 1024; +const TEXT_ARTIFACT_BATCH_PAGES_V1: usize = 32; +const TEXT_ARTIFACT_BATCH_BYTES_V1: usize = 64 * 1024 * 1024; /// One synchronous activation advances only this many page/finalization /// operations. Larger caller hints are clamped so work accounting cannot /// overflow and every expensive loop retains cancellation checkpoints. @@ -3110,6 +3110,12 @@ impl LatestCodeTextGenerationV1 { ) -> Result { let store = &self.text_artifact_store; if build.is_none() { + hotpath::gauge!("query.artifact.build_memory_budget_bytes") + .set(CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1); + hotpath::gauge!("query.artifact.source_batch_pages_max") + .set(TEXT_ARTIFACT_BATCH_PAGES_V1); + hotpath::gauge!("query.artifact.source_batch_bytes_max") + .set(TEXT_ARTIFACT_BATCH_BYTES_V1); let generation_id = self.metadata.manifest().generation_id.clone(); if let Some(descriptor) = store.published_descriptor(&generation_id)? { // Durable-head reopen: a restart serves the published From 4445b5ac41e79a48641e5a864b1245c6c825e397 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 17:05:57 +0000 Subject: [PATCH 54/69] perf(query): shard lexical ngram postings --- .../retrieval/lexical/projection/artifact.rs | 6 + .../lexical/projection/artifact/builder.rs | 335 ++++++++---- .../lexical/projection/artifact/format.rs | 188 ++++++- .../lexical/projection/artifact/prepared.rs | 195 +++++-- .../lexical/projection/artifact/reader.rs | 500 +++++++++++++++--- .../candidate_producers.rs | 155 ++++++ 6 files changed, 1160 insertions(+), 219 deletions(-) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs index 4ebf34d0b2..bffeb26f69 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs @@ -69,6 +69,12 @@ const ARTIFACT_SQLITE_CACHE_BYTES: usize = 64 * 1024 * 1024; /// The kernel SQLite window's page-cache floor. const ARTIFACT_SQLITE_CACHE_FLOOR_BYTES: usize = 2 * 1024 * 1024; const ARTIFACT_DOCUMENT_SCRATCH_LIMIT_BYTES: usize = 64 * 1024 * 1024; +/// Conservative live charge while one page's n-grams move from the ordered +/// key map and Roaring containers into canonical encoded shards. One logical +/// membership pays for a worst-case distinct B-tree entry/container plus the +/// sparse document value; the separately retained shard bytes cover encoded +/// output that overlaps the shrinking map. +const NGRAM_AGGREGATION_BYTES_PER_LOGICAL_POSTING_V1: usize = 160; #[derive(Debug, Error)] pub enum CodeLexicalArtifactErrorV1 { diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index 55ccdf973d..e17c1ffdef 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -29,9 +29,9 @@ use tracedecay_private_fs::{create_private_file_retained, open_private_file}; use super::format::{ CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1, CodeLexicalArtifactSectionDigestV1, RECEIPT_RESERVATION_BYTES, SECTION_NAMES, VerifiedCodeLexicalArtifactV1, artifact_digest, - decode_padded_receipt, decode_padded_receipt_with_control, encode_field, metadata_digest, - new_verified_receipt, padded_receipt, verify_artifact_table_layout, - verify_required_artifact_indexes, + decode_ngram_bitmap, decode_padded_receipt, decode_padded_receipt_with_control, encode_field, + metadata_digest, new_verified_receipt, ngram_page_digest, padded_receipt, + verify_artifact_table_layout, verify_required_artifact_indexes, }; use super::postings::document_ngram_scratch; use super::prepared::{PreparedCodeLexicalArtifactPageV1, prepare_page as prepare_page_values}; @@ -40,7 +40,8 @@ use super::{ CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1, CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1, CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1, CodeLexicalArtifactBatchLimitV1, - CodeLexicalArtifactErrorV1, checkpoint, open_builder_connection, sqlite_corrupt, sqlite_error, + CodeLexicalArtifactErrorV1, NGRAM_AGGREGATION_BYTES_PER_LOGICAL_POSTING_V1, checkpoint, + open_builder_connection, sqlite_corrupt, sqlite_error, }; use crate::retrieval::lexical::LexicalFieldV1; @@ -49,8 +50,6 @@ use super::super::CodeLexicalProjectionMetadataV1; const DOCUMENT_TERM_POSTINGS_QUERY: &str = "SELECT field, term, frequency FROM term_postings INDEXED BY term_postings_by_document WHERE document_id = ?1 ORDER BY field, term"; const DOCUMENT_EXACT_POSTINGS_QUERY: &str = "SELECT field, term FROM exact_postings WHERE document_id = ?1 ORDER BY field, term"; -const DOCUMENT_NGRAM_POSTINGS_QUERY: &str = - "SELECT kind, ngram FROM ngram_postings WHERE document_id = ?1 ORDER BY kind, ngram"; const PROGRESS_TAIL_QUERY: &str = "SELECT page_ordinal, import_dictionary_digest, cumulative_digest, next_cursor \ FROM source_pages ORDER BY page_ordinal DESC LIMIT 1"; const FINALIZATION_PROGRESS_INTERVAL_OPS: i32 = 4_096; @@ -123,7 +122,7 @@ impl FinalizationSectionV1 { const fn full_query(self) -> &'static str { match self { Self::SourcePages => { - "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor FROM source_pages ORDER BY page_ordinal" + "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, next_cursor FROM source_pages ORDER BY page_ordinal" } Self::DocumentIntegrity => { "SELECT document_id, chunk_id, digest FROM document_integrity ORDER BY document_id" @@ -142,7 +141,7 @@ impl FinalizationSectionV1 { "SELECT field, term, document_id FROM exact_postings ORDER BY field, term, document_id" } Self::NgramPostings => { - "SELECT kind, ngram, document_id FROM ngram_postings ORDER BY kind, ngram, document_id" + "SELECT page_ordinal, kind, ngram, documents, cardinality FROM ngram_postings ORDER BY page_ordinal, kind, ngram" } Self::FieldStatistics => "SELECT field, total_length FROM field_stats ORDER BY field", Self::TermStatistics => { @@ -156,10 +155,10 @@ impl FinalizationSectionV1 { const fn seek_query(self, after: bool) -> &'static str { match (self, after) { (Self::SourcePages, false) => { - "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor FROM source_pages ORDER BY page_ordinal LIMIT ?1" + "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, next_cursor FROM source_pages ORDER BY page_ordinal LIMIT ?1" } (Self::SourcePages, true) => { - "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor FROM source_pages WHERE page_ordinal > ?1 ORDER BY page_ordinal LIMIT ?2" + "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, next_cursor FROM source_pages WHERE page_ordinal > ?1 ORDER BY page_ordinal LIMIT ?2" } (Self::DocumentIntegrity, false) => { "SELECT document_id, chunk_id, digest FROM document_integrity ORDER BY document_id LIMIT ?1" @@ -198,10 +197,10 @@ impl FinalizationSectionV1 { "SELECT field, term, document_id FROM exact_postings WHERE (field, term, document_id) > (?1, ?2, ?3) ORDER BY field, term, document_id LIMIT ?4" } (Self::NgramPostings, false) => { - "SELECT kind, ngram, document_id FROM ngram_postings ORDER BY kind, ngram, document_id LIMIT ?1" + "SELECT page_ordinal, kind, ngram, documents, cardinality FROM ngram_postings ORDER BY page_ordinal, kind, ngram LIMIT ?1" } (Self::NgramPostings, true) => { - "SELECT kind, ngram, document_id FROM ngram_postings WHERE (kind, ngram, document_id) > (?1, ?2, ?3) ORDER BY kind, ngram, document_id LIMIT ?4" + "SELECT page_ordinal, kind, ngram, documents, cardinality FROM ngram_postings WHERE (page_ordinal, kind, ngram) > (?1, ?2, ?3) ORDER BY page_ordinal, kind, ngram LIMIT ?4" } (Self::FieldStatistics, false) => { "SELECT field, total_length FROM field_stats ORDER BY field LIMIT ?1" @@ -240,9 +239,9 @@ enum PersistedFinalizationKeyV1 { document_id: i64, }, IntegerIntegerInteger { + page_ordinal: i64, kind: i64, ngram: i64, - document_id: i64, }, TextText { field: String, @@ -1876,24 +1875,25 @@ fn projected_chunk_prepared_retained_upper_bound_bytes( let normalized_text_bytes = text_bytes; let (_, normalized_scratch) = document_ngram_scratch(normalized_text_bytes)?; let (_, raw_scratch) = document_ngram_scratch(text_bytes)?; - // Preparation retains `(kind, ngram)` tuples, not the `u32` scratch - // values. Vec growth can reserve up to the next geometric capacity, so - // two tuple slots are charged for every authorized scratch slot. - let ngram_tuple_slots = normalized_scratch + // Every authorized n-gram slot may become a distinct ordered-map key with + // its own Roaring container while already-encoded shards accumulate. + // The exact prepared ledger separately charges those encoded blobs. + let ngram_aggregation_bytes = normalized_scratch .checked_add(raw_scratch) .and_then(|bytes| bytes.checked_div(std::mem::size_of::())) - .and_then(|slots| slots.checked_mul(2)) - .and_then(|slots| slots.checked_mul(std::mem::size_of::<(i64, i64)>())) + .and_then(|slots| slots.checked_mul(NGRAM_AGGREGATION_BYTES_PER_LOGICAL_POSTING_V1)) .ok_or_else(|| { CodeLexicalArtifactErrorV1::Contract( - "lexical artifact prepared n-gram slot charge overflowed".to_owned(), + "lexical artifact prepared n-gram aggregation charge overflowed".to_owned(), ) })?; - transient.checked_add(ngram_tuple_slots).ok_or_else(|| { - CodeLexicalArtifactErrorV1::Contract( - "lexical artifact prepared document charge overflowed".to_owned(), - ) - }) + transient + .checked_add(ngram_aggregation_bytes) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact prepared document charge overflowed".to_owned(), + ) + }) } /// Page-level prepared ownership that is not attributable to one chunk or @@ -2206,7 +2206,9 @@ fn append_prepared_postings( ) .map_err(sqlite_error)?; let mut ngram_statement = transaction - .prepare_cached("INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (?1, ?2, ?3)") + .prepare_cached( + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (?1, ?2, ?3, ?4, ?5)", + ) .map_err(sqlite_error)?; for page in pages { for document in &page.documents { @@ -2230,11 +2232,18 @@ fn append_prepared_postings( ]) .map_err(sqlite_error)?; } - for (kind, ngram) in &document.ngram_postings { - ngram_statement - .execute(params![kind, ngram, document.document_id]) - .map_err(sqlite_error)?; - } + } + for shard in &page.ngram_shards { + checkpoint(control)?; + ngram_statement + .execute(params![ + i64::try_from(page.page_ordinal).map_err(contract_number)?, + shard.kind, + shard.ngram, + shard.documents.as_slice(), + i64::try_from(shard.cardinality).map_err(contract_number)?, + ]) + .map_err(sqlite_error)?; } } Ok(()) @@ -2281,7 +2290,7 @@ fn insert_prepared_source_page( ) -> Result<(), CodeLexicalArtifactErrorV1> { transaction .execute( - "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, next_cursor) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ i64::try_from(page.page_ordinal).map_err(contract_number)?, page.page_digest.as_str(), @@ -2291,6 +2300,7 @@ fn insert_prepared_source_page( i64::try_from(page.import_count).map_err(contract_number)?, i64::try_from(page.import_payload_bytes).map_err(contract_number)?, page.import_dictionary_digest.as_str(), + page.ngram_digest.as_str(), page.next_cursor.as_slice(), ], ) @@ -2349,6 +2359,7 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError import_count INTEGER NOT NULL, import_payload_bytes INTEGER NOT NULL, import_dictionary_digest TEXT NOT NULL, + ngram_digest TEXT NOT NULL, next_cursor BLOB NOT NULL ); -- Every derived document and import receives its digest in the @@ -2397,10 +2408,12 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError PRIMARY KEY(field, term, document_id) ) WITHOUT ROWID; CREATE TABLE ngram_postings ( + page_ordinal INTEGER NOT NULL, kind INTEGER NOT NULL, ngram INTEGER NOT NULL, - document_id INTEGER NOT NULL, - PRIMARY KEY(document_id, kind, ngram) + documents BLOB NOT NULL, + cardinality INTEGER NOT NULL CHECK(cardinality > 0), + PRIMARY KEY(page_ordinal, kind, ngram) ) WITHOUT ROWID; CREATE TABLE vocabulary (term TEXT PRIMARY KEY) WITHOUT ROWID; CREATE TRIGGER content_epoch_source_pages_insert AFTER INSERT ON source_pages BEGIN UPDATE content_epoch SET epoch = epoch + 1 WHERE singleton = 1; END; @@ -2415,6 +2428,8 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError CREATE TRIGGER immutable_import_integrity_delete BEFORE DELETE ON import_integrity BEGIN SELECT RAISE(ABORT, 'immutable lexical import integrity'); END; CREATE TRIGGER immutable_import_evidence_update BEFORE UPDATE ON import_evidence BEGIN SELECT RAISE(ABORT, 'immutable lexical import evidence'); END; CREATE TRIGGER immutable_import_evidence_delete BEFORE DELETE ON import_evidence BEGIN SELECT RAISE(ABORT, 'immutable lexical import evidence'); END; + CREATE TRIGGER immutable_ngram_postings_update BEFORE UPDATE ON ngram_postings BEGIN SELECT RAISE(ABORT, 'immutable lexical ngram postings'); END; + CREATE TRIGGER immutable_ngram_postings_delete BEFORE DELETE ON ngram_postings BEGIN SELECT RAISE(ABORT, 'immutable lexical ngram postings'); END; ", ) .map_err(sqlite_error) @@ -2482,7 +2497,7 @@ fn authenticated_authority_epoch( SELECT 1 FROM rows AS content LEFT JOIN document_integrity AS authority USING(document_id) WHERE authority.document_id IS NULL UNION ALL SELECT 1 FROM term_postings AS content LEFT JOIN document_integrity AS authority USING(document_id) WHERE authority.document_id IS NULL UNION ALL SELECT 1 FROM exact_postings AS content LEFT JOIN document_integrity AS authority USING(document_id) WHERE authority.document_id IS NULL - UNION ALL SELECT 1 FROM ngram_postings AS content LEFT JOIN document_integrity AS authority USING(document_id) WHERE authority.document_id IS NULL + UNION ALL SELECT 1 FROM ngram_postings AS content LEFT JOIN source_pages AS authority USING(page_ordinal) WHERE authority.page_ordinal IS NULL )", [], |row| row.get(0), @@ -2755,7 +2770,7 @@ fn build_serving_index_step( 6 => hotpath::measure_block!( "query.artifact.finalization.index.ngram_postings_by_ngram", transaction.execute_batch( - "CREATE UNIQUE INDEX ngram_postings_by_ngram ON ngram_postings(kind, ngram, document_id)", + "CREATE UNIQUE INDEX ngram_postings_by_ngram ON ngram_postings(kind, ngram, page_ordinal)", ) ), _ => { @@ -3047,15 +3062,15 @@ fn advance_section_rows( ( FinalizationSectionV1::NgramPostings, Some(PersistedFinalizationKeyV1::IntegerIntegerInteger { + page_ordinal, kind, ngram, - document_id, }), ) => advance_native_section_rows( transaction, section, section.seek_query(true), - params![kind, ngram, document_id, limit], + params![page_ordinal, kind, ngram, limit], state, control, ), @@ -3114,7 +3129,9 @@ fn advance_native_section_rows( } if matches!( section, - FinalizationSectionV1::DocumentIntegrity | FinalizationSectionV1::ImportIntegrity + FinalizationSectionV1::SourcePages + | FinalizationSectionV1::DocumentIntegrity + | FinalizationSectionV1::ImportIntegrity ) { verify_integrity_row(transaction, section, row, control)?; } @@ -3170,9 +3187,9 @@ fn native_row_key( }), FinalizationSectionV1::NgramPostings => { Ok(PersistedFinalizationKeyV1::IntegerIntegerInteger { - kind: row.get(0).map_err(sqlite_error)?, - ngram: row.get(1).map_err(sqlite_error)?, - document_id: row.get(2).map_err(sqlite_error)?, + page_ordinal: row.get(0).map_err(sqlite_error)?, + kind: row.get(1).map_err(sqlite_error)?, + ngram: row.get(2).map_err(sqlite_error)?, }) } FinalizationSectionV1::FieldStatistics | FinalizationSectionV1::Vocabulary => Ok( @@ -3191,14 +3208,28 @@ fn verify_integrity_row( row: &rusqlite::Row<'_>, control: &dyn CodeIndexExecutionControlV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { - let expected: String = row - .get(if section == FinalizationSectionV1::DocumentIntegrity { - 2 - } else { - 1 - }) - .map_err(sqlite_error)?; + let expected_column = match section { + FinalizationSectionV1::SourcePages => 8, + FinalizationSectionV1::DocumentIntegrity => 2, + FinalizationSectionV1::ImportIntegrity => 1, + _ => { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact integrity verification selected the wrong section".to_owned(), + )); + } + }; + let expected: String = row.get(expected_column).map_err(sqlite_error)?; let actual = match section { + FinalizationSectionV1::SourcePages => match row.get_ref(0).map_err(sqlite_error)? { + ValueRef::Integer(page_ordinal) if page_ordinal >= 0 => { + ngram_page_integrity_digest(transaction, page_ordinal, control)? + } + _ => { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact source-page ngram receipt has an invalid key".to_owned(), + )); + } + }, FinalizationSectionV1::DocumentIntegrity => match row.get_ref(0).map_err(sqlite_error)? { ValueRef::Integer(document) if document >= 0 => { document_integrity_digest(transaction, document, control)? @@ -3452,7 +3483,7 @@ fn document_integrity_digest( ) -> Result { checkpoint(control)?; let mut hasher = Sha256::new(); - hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v2\0"); + hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v3\0"); hasher.update(document.to_le_bytes()); let row_count = hash_document_table( transaction, @@ -3483,17 +3514,72 @@ fn document_integrity_digest( document, control, )?; - hash_document_table( - transaction, - &mut hasher, - "ngram_posting", - DOCUMENT_NGRAM_POSTINGS_QUERY, - document, - control, - )?; integrity_digest(hasher) } +fn ngram_page_integrity_digest( + transaction: &Transaction<'_>, + page_ordinal: i64, + control: &dyn CodeIndexExecutionControlV1, +) -> Result { + checkpoint(control)?; + let (chunk_count, cursor_bytes): (i64, Vec) = transaction + .query_row( + "SELECT chunk_count, next_cursor FROM source_pages WHERE page_ordinal = ?1", + [page_ordinal], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(sqlite_error)?; + let chunk_count = u64::try_from(chunk_count).map_err(contract_number)?; + let cursor = decode_cursor(&cursor_bytes)?; + let first_document = cursor + .emitted_chunks() + .checked_sub(chunk_count) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram page cursor regressed its chunk count".to_owned(), + ) + })?; + let final_document = cursor.emitted_chunks(); + let mut statement = transaction + .prepare( + "SELECT kind, ngram, documents, cardinality FROM ngram_postings WHERE page_ordinal = ?1 ORDER BY kind, ngram", + ) + .map_err(sqlite_error)?; + let mut query = statement.query([page_ordinal]).map_err(sqlite_error)?; + let mut shards = Vec::<(i64, i64, Vec, u64)>::new(); + while let Some(row) = query.next().map_err(sqlite_error)? { + checkpoint(control)?; + let kind = row.get(0).map_err(sqlite_error)?; + let ngram = row.get(1).map_err(sqlite_error)?; + let documents: Vec = row.get(2).map_err(sqlite_error)?; + let cardinality: i64 = row.get(3).map_err(sqlite_error)?; + let bitmap = decode_ngram_bitmap(&documents)?; + if i64::try_from(bitmap.len()).map_err(contract_number)? != cardinality + || bitmap.iter().any(|document| { + let document = u64::from(document); + document < first_document || document >= final_document + }) + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap disagrees with its source page".to_owned(), + )); + } + shards.push(( + kind, + ngram, + documents, + u64::try_from(cardinality).map_err(contract_number)?, + )); + } + ngram_page_digest( + u64::try_from(page_ordinal).map_err(contract_number)?, + shards.iter().map(|(kind, ngram, documents, cardinality)| { + (*kind, *ngram, documents.as_slice(), *cardinality) + }), + ) +} + fn hash_document_table( transaction: &Transaction<'_>, hasher: &mut Sha256, @@ -3919,16 +4005,29 @@ fn record_batch_import_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { #[cfg(feature = "hotpath")] fn record_batch_posting_metrics(pages: &[PreparedCodeLexicalArtifactPageV1]) { - let postings = pages + let relational_postings = pages .iter() .flat_map(|page| &page.documents) - .map(|document| { - document.term_postings.len() - + document.exact_postings.len() - + document.ngram_postings.len() - }) + .map(|document| document.term_postings.len() + document.exact_postings.len()) .sum::(); - hotpath::gauge!("query.artifact.batch.posting_rows_total").inc(postings as u64); + let ngram_shards = pages + .iter() + .map(|page| page.ngram_shards.len()) + .sum::(); + let ngram_documents = pages + .iter() + .flat_map(|page| &page.ngram_shards) + .map(|shard| shard.cardinality) + .sum::(); + let ngram_bytes = pages + .iter() + .flat_map(|page| &page.ngram_shards) + .map(|shard| shard.documents.len()) + .sum::(); + hotpath::gauge!("query.artifact.batch.posting_rows_total").inc(relational_postings as u64); + hotpath::gauge!("query.artifact.batch.ngram_shard_rows_total").inc(ngram_shards as u64); + hotpath::gauge!("query.artifact.batch.ngram_documents_total").inc(ngram_documents); + hotpath::gauge!("query.artifact.batch.ngram_bytes_total").inc(ngram_bytes as u64); } #[cfg(not(feature = "hotpath"))] @@ -4093,7 +4192,9 @@ fn contract_number(error: impl std::fmt::Display) -> CodeLexicalArtifactErrorV1 #[cfg(test)] mod tests { + use super::super::format::encode_ngram_bitmap; use super::*; + use roaring::RoaringBitmap; use rusqlite::StatementStatus; use std::sync::atomic::{AtomicUsize, Ordering}; use tracedecay_domain::{ @@ -4180,6 +4281,34 @@ mod tests { ); } + #[test] + fn canonical_write_limit_refuses_ngram_receipt_past_the_exact_boundary() { + let ngram_receipt_bytes = "sha256:".len() + 64; + let mut ledger = CanonicalBatchLimitLedgerV1::default(); + assert!( + ledger + .try_admit( + 0, + CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1 + - ngram_receipt_bytes, + ) + .expect("admit bytes below the ngram receipt boundary") + .is_none() + ); + let exceeded = ledger + .try_admit(0, ngram_receipt_bytes + 1) + .expect("evaluate ngram receipt boundary") + .expect("one byte past the write boundary must be refused"); + assert_eq!( + exceeded.limit, + CodeLexicalArtifactBatchLimitV1::EstimatedWriteBytes + ); + assert_eq!( + exceeded.required, + CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1 + 1 + ); + } + #[test] fn finalization_monitor_spawn_failure_is_typed_and_leaves_sqlite_reusable() { let mut connection = Connection::open_in_memory().expect("open artifact database"); @@ -4206,16 +4335,20 @@ mod tests { } #[test] - fn ngram_staging_key_preserves_document_order_without_a_serving_index() { + fn ngram_staging_key_preserves_source_page_order_without_a_serving_index() { let connection = Connection::open_in_memory().expect("open artifact database"); create_schema(&connection).expect("create artifact schema"); - for (document_id, ngram) in [(0i64, 90i64), (0, 100), (1, 10), (1, 20)] { + for (page_ordinal, ngram, document) in + [(0i64, 90i64, 0u32), (0, 100, 0), (1, 10, 1), (1, 20, 1)] + { + let bitmap = RoaringBitmap::from_iter([document]); + let encoded = encode_ngram_bitmap(&bitmap).expect("encode ngram bitmap"); connection .execute( - "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (1, ?1, ?2)", - params![ngram, document_id], + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (?1, 1, ?2, ?3, 1)", + params![page_ordinal, ngram, encoded], ) - .expect("seed document-ordered ngram posting"); + .expect("seed page-ordered ngram posting"); } let serving_indexes: i64 = connection @@ -4232,7 +4365,7 @@ mod tests { let mut statement = connection .prepare( - "SELECT document_id, kind, ngram FROM ngram_postings ORDER BY document_id, kind, ngram", + "SELECT page_ordinal, kind, ngram FROM ngram_postings ORDER BY page_ordinal, kind, ngram", ) .expect("prepare staging-order scan"); let rows = statement @@ -4250,7 +4383,7 @@ mod tests { assert_eq!( statement.get_status(StatementStatus::Sort), 0, - "document-order catch-up must be the maintained table order" + "source-page catch-up must be the maintained table order" ); } @@ -4258,11 +4391,17 @@ mod tests { fn deferred_ngram_serving_index_is_unique_and_query_selective() { let mut connection = Connection::open_in_memory().expect("open artifact database"); create_schema(&connection).expect("create artifact schema"); - for (document_id, ngram) in [(1i64, 10i64), (1, 20), (2, 10)] { + for (page_ordinal, ngram, documents) in [ + (0i64, 10i64, vec![1u32, 2]), + (0, 20, vec![1]), + (1, 10, vec![3]), + ] { + let bitmap = RoaringBitmap::from_iter(documents); + let encoded = encode_ngram_bitmap(&bitmap).expect("encode ngram bitmap"); connection .execute( - "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (1, ?1, ?2)", - params![ngram, document_id], + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (?1, 1, ?2, ?3, ?4)", + params![page_ordinal, ngram, encoded, bitmap.len() as i64], ) .expect("seed ngram posting"); } @@ -4293,36 +4432,30 @@ mod tests { .expect("query serving-index columns") .collect::, _>>() .expect("collect serving-index columns"); - assert_eq!(columns, ["kind", "ngram", "document_id"]); + assert_eq!(columns, ["kind", "ngram", "page_ordinal"]); - let query = "SELECT document_id FROM ngram_postings \ - WHERE kind = ?1 AND ngram IN (?2, ?3) \ - GROUP BY document_id HAVING COUNT(DISTINCT ngram) = ?4 \ - ORDER BY document_id"; + let query = "SELECT documents, cardinality FROM ngram_postings \ + WHERE kind = ?1 AND ngram = ?2 ORDER BY page_ordinal"; let plan = connection .prepare(&format!("EXPLAIN QUERY PLAN {query}")) .expect("prepare ngram serving plan") - .query_map(params![1i64, 10i64, 20i64, 2i64], |row| { - row.get::<_, String>(3) - }) + .query_map(params![1i64, 10i64], |row| row.get::<_, String>(3)) .expect("query ngram serving plan") .collect::, _>>() .expect("collect ngram serving plan"); assert!( plan.iter() - .any(|detail| detail.contains("USING COVERING INDEX ngram_postings_by_ngram")), + .any(|detail| detail.contains("USING INDEX ngram_postings_by_ngram")), "phrase candidates must use the deferred serving index, got {plan:?}" ); - let documents = connection + let shard_cardinalities = connection .prepare(query) .expect("prepare ngram serving query") - .query_map(params![1i64, 10i64, 20i64, 2i64], |row| { - row.get::<_, i64>(0) - }) + .query_map(params![1i64, 10i64], |row| row.get::<_, i64>(1)) .expect("query ngram candidates") .collect::, _>>() .expect("collect ngram candidates"); - assert_eq!(documents, [1]); + assert_eq!(shard_cardinalities, [2, 1]); } #[test] @@ -4405,7 +4538,7 @@ mod tests { } #[test] - fn one_document_integrity_row_does_not_visit_unrelated_generation_rows() { + fn one_document_integrity_row_does_not_visit_unrelated_relational_rows() { let mut connection = Connection::open_in_memory().expect("open artifact database"); create_schema(&connection).expect("create artifact schema"); let transaction = connection.transaction().expect("start seed transaction"); @@ -4422,28 +4555,18 @@ mod tests { params![document_id.to_le_bytes().as_slice(), document_id], ) .expect("seed exact posting"); - transaction - .execute( - "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (1, ?1, ?2)", - params![document_id, document_id], - ) - .expect("seed ngram posting"); } transaction.commit().expect("commit seed transaction"); let transaction = connection .transaction() .expect("start index-build transaction"); - for ordinal in [2, 5, 6] { + for ordinal in [2, 5] { build_serving_index_step(&transaction, ordinal) .expect("build document integrity index"); } transaction.commit().expect("commit integrity index"); - for query in [ - DOCUMENT_TERM_POSTINGS_QUERY, - DOCUMENT_EXACT_POSTINGS_QUERY, - DOCUMENT_NGRAM_POSTINGS_QUERY, - ] { + for query in [DOCUMENT_TERM_POSTINGS_QUERY, DOCUMENT_EXACT_POSTINGS_QUERY] { let mut statement = connection.prepare(query).expect("prepare integrity query"); { let mut rows = statement.query([1_024i64]).expect("query one document"); @@ -4539,7 +4662,7 @@ mod tests { create_schema(&connection).expect("create artifact schema"); connection .execute( - "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, next_cursor) VALUES (0, 'page', 'cumulative', 1, 1, 1, 1, 'imports', X'00')", + "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, next_cursor) VALUES (0, 'page', 'cumulative', 1, 1, 1, 1, 'imports', 'ngrams', X'00')", [], ) .expect("seed source page"); @@ -4579,10 +4702,12 @@ mod tests { [], ) .expect("seed exact posting"); + let encoded = + encode_ngram_bitmap(&RoaringBitmap::from_iter([0])).expect("encode ngram bitmap"); connection .execute( - "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (1, 1, 0)", - [], + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (0, 1, 1, ?1, 1)", + [encoded], ) .expect("seed ngram posting"); connection diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs index e2841c8e55..b7c79ac128 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; +use roaring::RoaringBitmap; use rusqlite::{Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -22,9 +23,11 @@ use super::CodeLexicalArtifactErrorV1; // adds term-selective read indexes. Revision 6 makes the append authority // immutable before one authenticated digest pass, defers every serving index // until resumable finalization, and keeps ngram catch-up document-leading. -pub(super) const CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1: u32 = 6; -const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"tracedecay.code-lexical-artifact.v6\0"; -const REQUIRED_ARTIFACT_INDEXES_V6: [(&str, &str, &[&str]); 7] = [ +// Revision 7 replaces one row per document n-gram with deterministic +// source-page Roaring bitmap shards. +pub(super) const CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1: u32 = 7; +const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"tracedecay.code-lexical-artifact.v7\0"; +const REQUIRED_ARTIFACT_INDEXES_V7: [(&str, &str, &[&str]); 7] = [ ("rows", "rows_by_chunk", &["chunk_id"]), ( "term_postings", @@ -50,7 +53,7 @@ const REQUIRED_ARTIFACT_INDEXES_V6: [(&str, &str, &[&str]); 7] = [ ( "ngram_postings", "ngram_postings_by_ngram", - &["kind", "ngram", "document_id"], + &["kind", "ngram", "page_ordinal"], ), ]; pub(super) const RECEIPT_RESERVATION_BYTES: usize = 16 * 1024; @@ -79,7 +82,7 @@ pub(super) fn verify_required_artifact_indexes( "artifact index schema is unreadable: {error}" )) })?; - for (table, index, expected_columns) in REQUIRED_ARTIFACT_INDEXES_V6 { + for (table, index, expected_columns) in REQUIRED_ARTIFACT_INDEXES_V7 { let partial: Option = connection .query_row( "SELECT partial FROM pragma_index_list(?1) WHERE name = ?2", @@ -172,9 +175,11 @@ pub(super) fn verify_artifact_table_layout( )) })?; let expected = [ + ("page_ordinal", "INTEGER", 1, 1), ("kind", "INTEGER", 1, 2), ("ngram", "INTEGER", 1, 3), - ("document_id", "INTEGER", 1, 1), + ("documents", "BLOB", 1, 0), + ("cardinality", "INTEGER", 1, 0), ]; if without_rowid != Some(1) || !columns @@ -185,12 +190,181 @@ pub(super) fn verify_artifact_table_layout( .eq(expected) { return Err(CodeLexicalArtifactErrorV1::Incompatible(format!( - "artifact ngram table has columns {columns:?} and without-rowid state {without_rowid:?}; revision {CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1} requires a document-leading primary key" + "artifact ngram table has columns {columns:?} and without-rowid state {without_rowid:?}; revision {CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1} requires source-page bitmap shards" ))); } Ok(()) } +pub(super) fn ngram_page_digest<'a>( + page_ordinal: u64, + rows: impl IntoIterator, +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-ngram-page.v1\0"); + hasher.update(page_ordinal.to_le_bytes()); + let mut row_count = 0u64; + for (kind, ngram, documents, cardinality) in rows { + hasher.update(b"row\0"); + hasher.update(kind.to_le_bytes()); + hasher.update(ngram.to_le_bytes()); + hasher.update( + u64::try_from(documents.len()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))? + .to_le_bytes(), + ); + hasher.update(documents); + hasher.update(cardinality.to_le_bytes()); + row_count = row_count.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram shard count overflowed".to_owned(), + ) + })?; + } + hasher.update(b"end\0"); + hasher.update(row_count.to_le_bytes()); + ManifestDigest::from_sha256_bytes(&hasher.finalize()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string())) +} + +pub(super) fn encode_ngram_bitmap( + bitmap: &RoaringBitmap, +) -> Result, CodeLexicalArtifactErrorV1> { + let cardinality = bitmap.len(); + let mut range_count = 0u64; + let mut previous: Option = None; + for document in bitmap.iter() { + if previous.is_none_or(|previous| document != previous.saturating_add(1)) { + range_count = range_count.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram range count overflowed".to_owned(), + ) + })?; + } + previous = Some(document); + } + let list_bytes = cardinality.checked_mul(4).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram document-list size overflowed".to_owned(), + ) + })?; + let range_bytes = range_count.checked_mul(8).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram range size overflowed".to_owned(), + ) + })?; + let ranges = range_bytes < list_bytes; + let payload_bytes = if ranges { range_bytes } else { list_bytes }; + let capacity = usize::try_from(payload_bytes) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))? + .checked_add(16) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram bitmap size overflowed".to_owned(), + ) + })?; + let mut encoded = Vec::with_capacity(capacity); + encoded.extend_from_slice(b"TDN1"); + encoded.push(u8::from(ranges)); + encoded.extend_from_slice(&[0u8; 3]); + encoded.extend_from_slice(&cardinality.to_le_bytes()); + if ranges { + let mut start: Option = None; + let mut previous: Option = None; + for document in bitmap.iter() { + if previous.is_some_and(|previous| document != previous.saturating_add(1)) { + let start_value = start.ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram range is missing its start".to_owned(), + ) + })?; + let previous_value = previous.ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram range is missing its end".to_owned(), + ) + })?; + encoded.extend_from_slice(&start_value.to_le_bytes()); + encoded.extend_from_slice(&(previous_value - start_value).to_le_bytes()); + start = Some(document); + } else if start.is_none() { + start = Some(document); + } + previous = Some(document); + } + if let (Some(start), Some(previous)) = (start, previous) { + encoded.extend_from_slice(&start.to_le_bytes()); + encoded.extend_from_slice(&(previous - start).to_le_bytes()); + } + } else { + for document in bitmap.iter() { + encoded.extend_from_slice(&document.to_le_bytes()); + } + } + Ok(encoded) +} + +pub(super) fn decode_ngram_bitmap( + encoded: &[u8], +) -> Result { + let header = encoded.get(..16).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap header is truncated".to_owned(), + ) + })?; + if &header[..4] != b"TDN1" || header[5..8] != [0u8; 3] || header[4] > 1 { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap header is invalid".to_owned(), + )); + } + let cardinality = u64::from_le_bytes(header[8..16].try_into().map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap cardinality is malformed".to_owned(), + ) + })?); + let width = if header[4] == 1 { 8usize } else { 4usize }; + if !(encoded.len() - 16).is_multiple_of(width) { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap payload is truncated".to_owned(), + )); + } + let mut bitmap = RoaringBitmap::new(); + let mut previous: Option = None; + for item in encoded[16..].chunks_exact(width) { + let start = u32::from_le_bytes(item[..4].try_into().map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap document is malformed".to_owned(), + ) + })?); + let end = if width == 8 { + let run = u32::from_le_bytes(item[4..8].try_into().map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap run is malformed".to_owned(), + ) + })?); + start.checked_add(run).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap run overflowed".to_owned(), + ) + })? + } else { + start + }; + if previous.is_some_and(|previous| start <= previous) { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap documents are not strictly ordered".to_owned(), + )); + } + bitmap.insert_range(start..=end); + previous = Some(end); + } + if bitmap.len() != cardinality { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact ngram bitmap cardinality does not verify".to_owned(), + )); + } + Ok(bitmap) +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct CodeLexicalArtifactSectionDigestV1 { diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs index 54ceaf7e69..af2daa9b38 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs @@ -1,5 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; +use roaring::RoaringBitmap; use sha2::{Digest, Sha256}; use tracedecay_code_index::production::{CodeIndexExecutionControlV1, VerifiedSealedLexicalPageV1}; use tracedecay_domain::{ExactFieldV1, ManifestDigest}; @@ -8,9 +9,13 @@ use super::super::{ CodeLexicalProjectionMetadataV1, ProjectedChunkV1, canonical_projected_exact_term, exact_field_for_kind, }; -use super::format::{ArtifactRowV1, encode_exact_field, encode_field}; +use super::format::{ + ArtifactRowV1, encode_exact_field, encode_field, encode_ngram_bitmap, ngram_page_digest, +}; use super::postings::{NGRAM_NORMALIZED, NGRAM_RAW_OVERRIDE, document_ngrams}; -use super::{CodeLexicalArtifactErrorV1, checkpoint}; +use super::{ + CodeLexicalArtifactErrorV1, NGRAM_AGGREGATION_BYTES_PER_LOGICAL_POSTING_V1, checkpoint, +}; #[derive(Debug)] pub struct PreparedCodeLexicalArtifactPageV1 { @@ -26,6 +31,8 @@ pub struct PreparedCodeLexicalArtifactPageV1 { pub(super) next_cursor: Vec, pub(super) imports: Vec, pub(super) documents: Vec, + pub(super) ngram_shards: Vec, + pub(super) ngram_digest: ManifestDigest, source_retained_bytes: usize, prepared_retained_bytes: usize, preparation_scratch_bytes: usize, @@ -91,10 +98,17 @@ pub(super) struct PreparedDocumentV1 { pub(super) row: Vec, pub(super) term_postings: Vec, pub(super) exact_postings: Vec<(String, Vec)>, - pub(super) ngram_postings: Vec<(i64, i64)>, pub(super) integrity_digest: ManifestDigest, } +#[derive(Debug)] +pub(super) struct PreparedNgramShardV1 { + pub(super) kind: i64, + pub(super) ngram: i64, + pub(super) documents: Vec, + pub(super) cardinality: u64, +} + #[derive(Debug)] pub(super) struct PreparedTermPostingV1 { pub(super) field: String, @@ -120,6 +134,8 @@ pub(super) fn prepare_page( ) })?; let mut documents = Vec::with_capacity(page.chunks().len()); + let mut ngram_documents = BTreeMap::<(i64, i64), RoaringBitmap>::new(); + let mut logical_ngram_postings = 0usize; for (offset, admitted) in page.chunks().iter().enumerate() { checkpoint(control)?; let document = first_document @@ -129,12 +145,27 @@ pub(super) fn prepare_page( "lexical artifact prepared document id overflowed".to_owned(), ) })?; - documents.push(prepare_document( + let (prepared, ngrams) = prepare_document( metadata, i64::try_from(document).map_err(contract_number)?, admitted.chunk(), control, - )?); + )?; + let document = u32::try_from(prepared.document_id).map_err(contract_number)?; + logical_ngram_postings = logical_ngram_postings + .checked_add(ngrams.len()) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram aggregation count overflowed".to_owned(), + ) + })?; + for (kind, ngram) in ngrams { + ngram_documents + .entry((kind, ngram)) + .or_default() + .insert(document); + } + documents.push(prepared); } let mut imports = Vec::with_capacity(page.imports().len()); for evidence in page.imports() { @@ -151,6 +182,42 @@ pub(super) fn prepare_page( .next_cursor() .persisted_bytes() .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + let mut ngram_shards = Vec::with_capacity(ngram_documents.len()); + for ((kind, ngram), documents) in ngram_documents { + checkpoint(control)?; + let encoded = encode_ngram_bitmap(&documents)?; + ngram_shards.push(PreparedNgramShardV1 { + kind, + ngram, + documents: encoded, + cardinality: documents.len(), + }); + } + let ngram_digest = ngram_page_digest( + page.page_ordinal(), + ngram_shards.iter().map(|shard| { + ( + shard.kind, + shard.ngram, + shard.documents.as_slice(), + shard.cardinality, + ) + }), + )?; + let aggregation_scratch_bytes = logical_ngram_postings + .checked_mul(NGRAM_AGGREGATION_BYTES_PER_LOGICAL_POSTING_V1) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact ngram aggregation charge overflowed".to_owned(), + ) + })?; + let preparation_scratch_bytes = preparation_scratch_bytes + .checked_add(aggregation_scratch_bytes) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page preparation scratch charge overflowed".to_owned(), + ) + })?; let mut prepared = PreparedCodeLexicalArtifactPageV1 { page_ordinal: page.page_ordinal(), page_digest: page.page_digest().clone(), @@ -164,6 +231,8 @@ pub(super) fn prepare_page( next_cursor, imports, documents, + ngram_shards, + ngram_digest, source_retained_bytes: page.retained_owned_bytes(), prepared_retained_bytes: 0, preparation_scratch_bytes, @@ -183,7 +252,7 @@ fn prepare_document( document_id: i64, chunk: &tracedecay_domain::CodeSearchChunkV1, control: &dyn CodeIndexExecutionControlV1, -) -> Result { +) -> Result<(PreparedDocumentV1, Vec<(i64, i64)>), CodeLexicalArtifactErrorV1> { u32::try_from(document_id).map_err(|_| { CodeLexicalArtifactErrorV1::Contract( "lexical artifact exceeds the posting document-id range".to_owned(), @@ -270,17 +339,18 @@ fn prepare_document( &row, &term_postings, &exact_postings, - &ngram_postings, )?; - Ok(PreparedDocumentV1 { - document_id, - chunk_id, - row, - term_postings, - exact_postings, + Ok(( + PreparedDocumentV1 { + document_id, + chunk_id, + row, + term_postings, + exact_postings, + integrity_digest, + }, ngram_postings, - integrity_digest, - }) + )) } fn document_integrity_digest( @@ -289,10 +359,9 @@ fn document_integrity_digest( row: &[u8], term_postings: &[PreparedTermPostingV1], exact_postings: &[(String, Vec)], - ngram_postings: &[(i64, i64)], ) -> Result { let mut hasher = Sha256::new(); - hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v2\0"); + hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v3\0"); hasher.update(document.to_le_bytes()); hash_table(&mut hasher, "row", 1, |hasher, _| { hash_text(hasher, chunk_id)?; @@ -320,17 +389,6 @@ fn document_integrity_digest( hash_blob(hasher, term) }, )?; - hash_table( - &mut hasher, - "ngram_posting", - ngram_postings.len(), - |hasher, ordinal| { - let (kind, ngram) = ngram_postings[ordinal]; - hash_integer(hasher, kind); - hash_integer(hasher, ngram); - Ok(()) - }, - )?; integrity_digest(hasher) } @@ -425,6 +483,14 @@ fn prepared_retained_bytes( .saturating_mul(std::mem::size_of::()), ) }) + .and_then(|bytes| { + bytes.checked_add( + page.ngram_shards + .capacity() + .saturating_mul(std::mem::size_of::()), + ) + }) + .and_then(|bytes| bytes.checked_add(page.ngram_digest.as_str().len())) .ok_or_else(prepared_charge_overflow)?; for import in &page.imports { bytes = bytes @@ -453,14 +519,6 @@ fn prepared_retained_bytes( .saturating_mul(std::mem::size_of::<(String, Vec)>()), ) }) - .and_then(|bytes| { - bytes.checked_add( - document - .ngram_postings - .capacity() - .saturating_mul(std::mem::size_of::<(i64, i64)>()), - ) - }) .ok_or_else(prepared_charge_overflow)?; for posting in &document.term_postings { bytes = bytes @@ -475,6 +533,11 @@ fn prepared_retained_bytes( .ok_or_else(prepared_charge_overflow)?; } } + for shard in &page.ngram_shards { + bytes = bytes + .checked_add(shard.documents.capacity()) + .ok_or_else(prepared_charge_overflow)?; + } Ok(bytes) } @@ -482,14 +545,13 @@ fn estimated_sqlite_writes( page: &PreparedCodeLexicalArtifactPageV1, ) -> Result<(usize, usize), CodeLexicalArtifactErrorV1> { let mut rows = 1usize; - let mut bytes = page - .page_digest - .as_str() - .len() - .checked_add(page.cumulative_digest.as_str().len()) - .and_then(|bytes| bytes.checked_add(page.import_dictionary_digest.as_str().len())) - .and_then(|bytes| bytes.checked_add(page.next_cursor.len())) - .ok_or_else(prepared_write_overflow)?; + let mut bytes = estimated_source_page_receipt_write_bytes( + page.page_digest.as_str(), + page.cumulative_digest.as_str(), + page.import_dictionary_digest.as_str(), + page.ngram_digest.as_str(), + &page.next_cursor, + )?; for import in &page.imports { rows = rows.checked_add(2).ok_or_else(prepared_write_overflow)?; bytes = bytes @@ -519,16 +581,35 @@ fn estimated_sqlite_writes( .and_then(|bytes| bytes.checked_add(8)) .ok_or_else(prepared_write_overflow)?; } - rows = rows - .checked_add(document.ngram_postings.len()) - .ok_or_else(prepared_write_overflow)?; + } + rows = rows + .checked_add(page.ngram_shards.len()) + .ok_or_else(prepared_write_overflow)?; + for shard in &page.ngram_shards { bytes = bytes - .checked_add(document.ngram_postings.len().saturating_mul(24)) + .checked_add(shard.documents.len()) + .and_then(|bytes| bytes.checked_add(32)) .ok_or_else(prepared_write_overflow)?; } Ok((rows, bytes)) } +fn estimated_source_page_receipt_write_bytes( + page_digest: &str, + cumulative_digest: &str, + import_dictionary_digest: &str, + ngram_digest: &str, + next_cursor: &[u8], +) -> Result { + page_digest + .len() + .checked_add(cumulative_digest.len()) + .and_then(|bytes| bytes.checked_add(import_dictionary_digest.len())) + .and_then(|bytes| bytes.checked_add(ngram_digest.len())) + .and_then(|bytes| bytes.checked_add(next_cursor.len())) + .ok_or_else(prepared_write_overflow) +} + fn prepared_write_overflow() -> CodeLexicalArtifactErrorV1 { CodeLexicalArtifactErrorV1::Contract( "prepared lexical page estimated SQLite write overflowed".to_owned(), @@ -544,3 +625,21 @@ fn prepared_charge_overflow() -> CodeLexicalArtifactErrorV1 { fn contract_number(error: impl std::fmt::Display) -> CodeLexicalArtifactErrorV1 { CodeLexicalArtifactErrorV1::Contract(error.to_string()) } + +#[cfg(test)] +mod tests { + use super::estimated_source_page_receipt_write_bytes; + + #[test] + fn source_page_write_charge_includes_the_ngram_receipt_exactly() { + let without_ngram = + estimated_source_page_receipt_write_bytes("p", "cc", "iii", "", b"cursor") + .expect("receipt charge without ngram digest"); + let with_ngram = + estimated_source_page_receipt_write_bytes("p", "cc", "iii", "nnnnn", b"cursor") + .expect("receipt charge with ngram digest"); + + assert_eq!(without_ngram, 1 + 2 + 3 + 6); + assert_eq!(with_ngram, without_ngram + 5); + } +} diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs index dce97b136a..b5230ca8f5 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs @@ -2,11 +2,13 @@ use std::cell::Cell; use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; +use std::fmt::Write as _; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; use std::sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard}; +use roaring::RoaringBitmap; #[cfg(any(test, feature = "hotpath"))] use rusqlite::StatementStatus; use rusqlite::{Connection, OpenFlags, OptionalExtension, params_from_iter, types::Value}; @@ -25,7 +27,7 @@ use super::builder::compute_section_digests; use super::format::{ ArtifactRowV1, CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1, CodeLexicalArtifactOccurrenceV1, CodeLexicalImportMembershipWitnessV1, VerifiedCodeLexicalArtifactV1, artifact_digest, - decode_padded_receipt, encode_exact_field, encode_field, metadata_digest, + decode_ngram_bitmap, decode_padded_receipt, encode_exact_field, encode_field, metadata_digest, verify_required_artifact_indexes, }; use super::postings::{NGRAM_NORMALIZED, NGRAM_RAW_OVERRIDE, query_ngrams}; @@ -520,6 +522,10 @@ struct ArtifactQueryMetricsV1 { probes: Cell, #[cfg(test)] fullscan_steps: Cell, + #[cfg(test)] + ngram_decoded_shards: Cell, + #[cfg(test)] + ngram_peak_candidates: Cell, } impl ArtifactQueryMetricsV1 { @@ -566,6 +572,18 @@ impl ArtifactQueryMetricsV1 { fn observed_fullscan_steps(&self) -> u64 { self.fullscan_steps.get() } + + #[cfg(test)] + fn observe_ngram_shard(&self) { + self.ngram_decoded_shards + .set(self.ngram_decoded_shards.get().saturating_add(1)); + } + + #[cfg(test)] + fn observe_ngram_candidates(&self, candidates: u64) { + self.ngram_peak_candidates + .set(self.ngram_peak_candidates.get().max(candidates)); + } } /// A SQLite-owned candidate set. The query is evaluated row-by-row, so Rust @@ -576,6 +594,7 @@ impl ArtifactQueryMetricsV1 { struct DocumentQueryV1 { sql: Option, parameters: Vec, + maximum_bound_value_bytes: usize, } impl DocumentQueryV1 { @@ -583,6 +602,7 @@ impl DocumentQueryV1 { Self { sql: None, parameters: Vec::new(), + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, } } @@ -592,6 +612,7 @@ impl DocumentQueryV1 { "SELECT document_id FROM term_postings WHERE field = ? AND term = ?".to_owned(), ), parameters: vec![Value::Text(field), Value::Text(term)], + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, } } @@ -601,6 +622,7 @@ impl DocumentQueryV1 { "SELECT document_id FROM term_postings WHERE term = ? AND field != ?".to_owned(), ), parameters: vec![Value::Text(term), Value::Text(excluded_field)], + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, } } @@ -610,6 +632,7 @@ impl DocumentQueryV1 { "SELECT document_id FROM exact_postings WHERE field = ? AND term = ?".to_owned(), ), parameters: vec![Value::Text(field), Value::Blob(term)], + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, } } } @@ -636,6 +659,11 @@ fn union_document_queries( // occurrence needlessly crosses the portable 999-variable ceiling even // though the request's distinct values remain bounded. Named parameters // also remain safe when this query is embedded in the frequency probe. + let maximum_bound_value_bytes = queries + .iter() + .map(|query| query.maximum_bound_value_bytes) + .max() + .unwrap_or(ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1); let mut parameters = Vec::new(); let mut level = queries .into_iter() @@ -667,6 +695,7 @@ fn union_document_queries( "SELECT DISTINCT document_id FROM ({root}) ORDER BY document_id" )), parameters, + maximum_bound_value_bytes, }) } @@ -715,7 +744,11 @@ fn visit_document_ids( return Ok(()); }; ensure_sqlite_bind_capacity(0, query.parameters.len())?; - ensure_sqlite_bound_value_bytes(&query.parameters, std::iter::empty())?; + ensure_sqlite_bound_value_bytes( + query.maximum_bound_value_bytes, + &query.parameters, + std::iter::empty(), + )?; let mut statement = connection.prepare(sql).map_err(map_query_sql_error)?; let mut rows = statement .query(params_from_iter(query.parameters.iter())) @@ -746,7 +779,11 @@ fn visit_lexical_rows( return Ok(()); }; ensure_sqlite_bind_capacity(documents.parameters.len(), terms.len())?; - ensure_sqlite_bound_value_bytes(&documents.parameters, terms.iter().map(String::as_str))?; + ensure_sqlite_bound_value_bytes( + documents.maximum_bound_value_bytes, + &documents.parameters, + terms.iter().map(String::as_str), + )?; let mut parameters = Vec::with_capacity(documents.parameters.len().saturating_add(terms.len())); let frequencies = if terms.is_empty() { @@ -815,6 +852,34 @@ const ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1: usize = 999; /// individual SQLite call deterministically bounded instead. const ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1: usize = ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1 * MAX_LEXICAL_QUERY_TERM_BYTES_V1; +/// A phrase prefilter may legitimately name more documents than request text +/// can occupy. Keep its one transient JSON1 bridge distinct from the generic +/// query-input bound and below one eighth of the reader cache authority. +const ARTIFACT_NGRAM_CANDIDATE_JSON_BYTES_V1: usize = + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1 / 8; +/// Bitmap queries may inspect only this many source-page shards per port call. +/// The read port carries no execution-control handle, so this fixed work bound +/// is the cancellation/deadline yield authority before control returns to the +/// caller. A 4 KiB work unit leaves authority for blob decode and intersection. +const ARTIFACT_NGRAM_QUERY_MAX_SHARDS_V1: usize = + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1 / (4 * 1024); +/// One encoded source-page shard is retained only while it is decoded and +/// intersected. Keep that transient allocation below one eighth of the cache. +const ARTIFACT_NGRAM_MAX_ENCODED_SHARD_BYTES_V1: usize = + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1 / 8; +/// The synchronous query may inspect at most one quarter of the reader cache +/// in encoded shard bytes across all selected n-grams. +const ARTIFACT_NGRAM_QUERY_ENCODED_BYTES_V1: usize = + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1 / 4; +/// A sparse Roaring candidate can require containers and two-byte values in +/// addition to its identifiers. Eight bytes per admitted identifier is a +/// conservative authority that bounds the first (rarest) full union. +const ARTIFACT_NGRAM_CANDIDATE_BITMAP_BYTES_V1: usize = + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1 / 4; +const ARTIFACT_NGRAM_CANDIDATE_BYTES_PER_DOCUMENT_V1: usize = 8; +const ARTIFACT_NGRAM_MAX_CANDIDATES_V1: u64 = (ARTIFACT_NGRAM_CANDIDATE_BITMAP_BYTES_V1 + / ARTIFACT_NGRAM_CANDIDATE_BYTES_PER_DOCUMENT_V1) + as u64; fn ensure_sqlite_bind_capacity( fixed_parameters: usize, @@ -830,6 +895,7 @@ fn ensure_sqlite_bind_capacity( } fn ensure_sqlite_bound_value_bytes<'a>( + maximum_bytes: usize, fixed_parameters: &[Value], dynamic_text: impl IntoIterator, ) -> Result<(), RetrievalPortError> { @@ -850,7 +916,7 @@ fn ensure_sqlite_bound_value_bytes<'a>( .checked_add(value.len()) .ok_or(RetrievalPortError::BudgetExceeded) })?; - if total_bytes > ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1 { + if total_bytes > maximum_bytes { return Err(RetrievalPortError::BudgetExceeded); } Ok(()) @@ -859,30 +925,226 @@ fn ensure_sqlite_bound_value_bytes<'a>( /// The first fixed number of distinct n-grams forms a selective, bounded /// prefilter. It may admit a superset for a very long phrase; the row-level /// substring check remains the correctness authority before scoring. -fn ngram_document_query(kind: i64, bytes: &[u8]) -> DocumentQueryV1 { - let ngrams = query_ngrams(bytes) - .into_iter() - .take(ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1) - .collect::>(); - if ngrams.is_empty() { - return DocumentQueryV1::empty(); - } - let placeholders = std::iter::repeat_n("?", ngrams.len()) - .collect::>() - .join(", "); - let mut parameters = Vec::with_capacity(ngrams.len() + 2); - parameters.push(Value::Integer(kind)); - parameters.extend(ngrams.iter().map(|ngram| Value::Integer(i64::from(*ngram)))); - parameters.push(Value::Integer(ngrams.len() as i64)); - DocumentQueryV1 { - sql: Some(format!( - "SELECT document_id FROM ngram_postings \ - WHERE kind = ? AND ngram IN ({placeholders}) \ - GROUP BY document_id HAVING COUNT(DISTINCT ngram) = ? \ - ORDER BY document_id" - )), - parameters, +fn ngram_document_query( + connection: &Connection, + kind: i64, + bytes: &[u8], + metrics: &ArtifactQueryMetricsV1, +) -> Result { + hotpath::measure_block!("query.artifact.ngram.bitmap_query", { + let ngrams = query_ngrams(bytes) + .into_iter() + .take(ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1) + .collect::>(); + if ngrams.is_empty() { + return Ok(DocumentQueryV1::empty()); + } + let candidates = ngram_bitmap_candidates(connection, kind, &ngrams, metrics)?; + let encoded = + encode_ngram_candidate_json(&candidates, ARTIFACT_NGRAM_CANDIDATE_JSON_BYTES_V1)?; + #[cfg(feature = "hotpath")] + hotpath::gauge!("query.artifact.ngram.query_candidates_total").inc(candidates.len()); + Ok(DocumentQueryV1 { + sql: Some( + "SELECT CAST(value AS INTEGER) AS document_id FROM json_each(?) ORDER BY document_id" + .to_owned(), + ), + parameters: vec![Value::Text(encoded)], + maximum_bound_value_bytes: ARTIFACT_NGRAM_CANDIDATE_JSON_BYTES_V1, + }) + }) +} + +#[derive(Clone, Copy)] +struct NgramSelectivityV1 { + ngram: u32, + cardinality: u64, +} + +fn ngram_bitmap_candidates( + connection: &Connection, + kind: i64, + ngrams: &[u32], + _metrics: &ArtifactQueryMetricsV1, +) -> Result { + let mut remaining_shards = ARTIFACT_NGRAM_QUERY_MAX_SHARDS_V1; + let mut remaining_encoded_bytes = ARTIFACT_NGRAM_QUERY_ENCODED_BYTES_V1; + let mut selectivities = Vec::with_capacity(ngrams.len()); + let mut selectivity_statement = connection + .prepare( + "SELECT cardinality, length(documents) FROM ngram_postings INDEXED BY ngram_postings_by_ngram WHERE kind = ?1 AND ngram = ?2 ORDER BY page_ordinal LIMIT ?3", + ) + .map_err(map_query_sql_error)?; + for ngram in ngrams { + let row_limit = remaining_shards + .checked_add(1) + .ok_or(RetrievalPortError::BudgetExceeded)?; + let mut rows = selectivity_statement + .query([ + kind, + i64::from(*ngram), + i64::try_from(row_limit).map_err(contract_error)?, + ]) + .map_err(map_query_sql_error)?; + let mut cardinality = 0u64; + let mut observed_shards = 0usize; + while let Some(row) = rows.next().map_err(map_query_sql_error)? { + if observed_shards == remaining_shards { + return Err(RetrievalPortError::BudgetExceeded); + } + let shard_cardinality: i64 = row.get(0).map_err(map_query_sql_error)?; + let encoded_bytes: i64 = row.get(1).map_err(map_query_sql_error)?; + let shard_cardinality = u64::try_from(shard_cardinality).map_err(contract_error)?; + let encoded_bytes = usize::try_from(encoded_bytes).map_err(contract_error)?; + if shard_cardinality == 0 { + return Err(RetrievalPortError::BudgetExceeded); + } + charge_ngram_encoded_shard_bytes( + &mut remaining_encoded_bytes, + encoded_bytes, + ARTIFACT_NGRAM_MAX_ENCODED_SHARD_BYTES_V1, + )?; + cardinality = cardinality + .checked_add(shard_cardinality) + .ok_or(RetrievalPortError::BudgetExceeded)?; + observed_shards = observed_shards + .checked_add(1) + .ok_or(RetrievalPortError::BudgetExceeded)?; + } + drop(rows); + if cardinality == 0 { + return Ok(RoaringBitmap::new()); + } + remaining_shards = remaining_shards + .checked_sub(observed_shards) + .ok_or(RetrievalPortError::BudgetExceeded)?; + selectivities.push(NgramSelectivityV1 { + ngram: *ngram, + cardinality, + }); + } + drop(selectivity_statement); + selectivities.sort_unstable_by_key(|selectivity| (selectivity.cardinality, selectivity.ngram)); + if let Some(selectivity) = selectivities.first() { + ensure_ngram_candidate_cardinality(selectivity.cardinality)?; + } + + let mut candidates = None::; + #[cfg(feature = "hotpath")] + let mut observed_shards = 0u64; + #[cfg(feature = "hotpath")] + let mut observed_bytes = 0u64; + let mut statement = connection + .prepare( + "SELECT documents, cardinality FROM ngram_postings INDEXED BY ngram_postings_by_ngram WHERE kind = ?1 AND ngram = ?2 ORDER BY page_ordinal", + ) + .map_err(map_query_sql_error)?; + for selectivity in selectivities { + let mut documents = RoaringBitmap::new(); + let mut rows = statement + .query([kind, i64::from(selectivity.ngram)]) + .map_err(map_query_sql_error)?; + while let Some(row) = rows.next().map_err(map_query_sql_error)? { + let encoded: Vec = row.get(0).map_err(map_query_sql_error)?; + let cardinality: i64 = row.get(1).map_err(map_query_sql_error)?; + let shard = decode_ngram_bitmap(&encoded).map_err(map_query_artifact_error)?; + if i64::try_from(shard.len()).map_err(contract_error)? != cardinality { + return Err(RetrievalPortError::Contract( + "lexical artifact ngram shard cardinality changed after verification" + .to_owned(), + )); + } + if let Some(candidates) = candidates.as_ref() { + documents |= &shard & candidates; + } else { + documents |= shard; + } + ensure_ngram_candidate_cardinality(documents.len())?; + #[cfg(test)] + { + _metrics.observe_ngram_shard(); + _metrics.observe_ngram_candidates(documents.len()); + } + #[cfg(feature = "hotpath")] + { + observed_shards = observed_shards.saturating_add(1); + observed_bytes = observed_bytes.saturating_add(encoded.len() as u64); + } + } + drop(rows); + candidates = Some(documents); + if candidates.as_ref().is_none_or(RoaringBitmap::is_empty) { + break; + } + } + let candidates = candidates.unwrap_or_default(); + #[cfg(feature = "hotpath")] + { + hotpath::gauge!("query.artifact.ngram.query_shards_total").inc(observed_shards); + hotpath::gauge!("query.artifact.ngram.query_bytes_total").inc(observed_bytes); + } + Ok(candidates) +} + +fn ensure_ngram_candidate_cardinality(cardinality: u64) -> Result<(), RetrievalPortError> { + if cardinality > ARTIFACT_NGRAM_MAX_CANDIDATES_V1 { + Err(RetrievalPortError::BudgetExceeded) + } else { + Ok(()) + } +} + +fn charge_ngram_encoded_shard_bytes( + remaining_bytes: &mut usize, + shard_bytes: usize, + maximum_shard_bytes: usize, +) -> Result<(), RetrievalPortError> { + if shard_bytes > maximum_shard_bytes { + return Err(RetrievalPortError::BudgetExceeded); + } + *remaining_bytes = remaining_bytes + .checked_sub(shard_bytes) + .ok_or(RetrievalPortError::BudgetExceeded)?; + Ok(()) +} + +fn encode_ngram_candidate_json( + candidates: &RoaringBitmap, + maximum_bytes: usize, +) -> Result { + if maximum_bytes < 2 { + return Err(RetrievalPortError::BudgetExceeded); + } + let capacity = usize::try_from(candidates.len()) + .map_err(contract_error)? + .checked_mul(11) + .and_then(|bytes| bytes.checked_add(2)) + .ok_or(RetrievalPortError::BudgetExceeded)? + .min(maximum_bytes); + let mut encoded = String::with_capacity(capacity); + encoded.push('['); + for (ordinal, document) in candidates.iter().enumerate() { + let digits = if document == 0 { + 1 + } else { + usize::try_from(document.ilog10()).map_err(contract_error)? + 1 + }; + let additional = digits + usize::from(ordinal != 0); + if encoded + .len() + .checked_add(additional) + .and_then(|bytes| bytes.checked_add(1)) + .is_none_or(|bytes| bytes > maximum_bytes) + { + return Err(RetrievalPortError::BudgetExceeded); + } + if ordinal != 0 { + encoded.push(','); + } + write!(&mut encoded, "{document}").map_err(contract_error)?; } + encoded.push(']'); + Ok(encoded) } impl<'a> ArtifactQueryV1<'a> { @@ -907,15 +1169,17 @@ impl<'a> ArtifactQueryV1<'a> { let fuzzy = self.fuzzy_expansions(request)?; let terms = lexical_terms(request, &fuzzy); let stats = self.lexical_stats(&terms)?; - let phrase_queries = request - .phrases - .iter() - .map(|phrase| { - let normalized = normalize_lexical(phrase); - let query = ngram_document_query(NGRAM_NORMALIZED, normalized.as_bytes()); - (normalized, query) - }) - .collect::>(); + let mut phrase_queries = BTreeMap::new(); + for phrase in &request.phrases { + let normalized = normalize_lexical(phrase); + let query = ngram_document_query( + self.connection, + NGRAM_NORMALIZED, + normalized.as_bytes(), + &self.metrics, + )?; + phrase_queries.insert(normalized, query); + } let mut phrase_frequencies = phrase_queries .keys() .cloned() @@ -1170,13 +1434,17 @@ impl<'a> ArtifactQueryV1<'a> { | ExactFieldV1::CompilerOrRuntimeError ) { sources.push(ngram_document_query( + self.connection, NGRAM_NORMALIZED, &literal.original_bytes, - )); + &self.metrics, + )?); sources.push(ngram_document_query( + self.connection, NGRAM_RAW_OVERRIDE, &literal.original_bytes, - )); + &self.metrics, + )?); } let field = encode_exact_field(literal.field).map_err(map_query_artifact_error)?; sources.push(DocumentQueryV1::exact( @@ -1299,7 +1567,11 @@ impl<'a> ArtifactQueryV1<'a> { terms: &BTreeSet, ) -> Result { ensure_sqlite_bind_capacity(0, terms.len())?; - ensure_sqlite_bound_value_bytes(&[], terms.iter().map(String::as_str))?; + ensure_sqlite_bound_value_bytes( + ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, + &[], + terms.iter().map(String::as_str), + )?; let mut field_totals = BTreeMap::new(); self.metrics.probe(); let mut statement = self @@ -2051,17 +2323,21 @@ mod tests { use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicUsize, Ordering}; + use roaring::RoaringBitmap; use rusqlite::{Connection, params}; use tracedecay_domain::ManifestDigest; use tracedecay_private_fs::open_private_file; + use super::super::format::encode_ngram_bitmap; #[cfg(feature = "hotpath")] use super::ArtifactConnectionMutex; use super::{ - ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1, ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1, - ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, ArtifactQueryMetricsV1, - CodeLexicalArtifactErrorV1, CodeLexicalArtifactReaderV1, DocumentQueryV1, NGRAM_NORMALIZED, - map_query_artifact_error, ngram_document_query, query_ngrams, retain_bounded, + ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1, ARTIFACT_NGRAM_MAX_CANDIDATES_V1, + ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1, ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, + ArtifactQueryMetricsV1, CodeLexicalArtifactErrorV1, CodeLexicalArtifactReaderV1, + DocumentQueryV1, NGRAM_NORMALIZED, charge_ngram_encoded_shard_bytes, + encode_ngram_candidate_json, ensure_ngram_candidate_cardinality, map_query_artifact_error, + ngram_bitmap_candidates, ngram_document_query, query_ngrams, retain_bounded, union_document_queries, visit_document_ids, visit_lexical_rows, }; use tracedecay_code_index::production::CodeIndexExecutionControlV1; @@ -2298,10 +2574,14 @@ mod tests { connection .execute_batch( "CREATE TABLE ngram_postings ( + page_ordinal INTEGER NOT NULL, kind INTEGER NOT NULL, ngram INTEGER NOT NULL, - document_id INTEGER NOT NULL - );", + documents BLOB NOT NULL, + cardinality INTEGER NOT NULL, + PRIMARY KEY(page_ordinal, kind, ngram) + ) WITHOUT ROWID; + CREATE UNIQUE INDEX ngram_postings_by_ngram ON ngram_postings(kind, ngram, page_ordinal);", ) .expect("ngram fixture schema"); let phrase = b"abcdefghijklmnopqrstuvw"; @@ -2311,29 +2591,129 @@ mod tests { .collect::>(); assert_eq!(ngrams.len(), ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1); for (ordinal, ngram) in ngrams.iter().enumerate() { + let documents = if ordinal + 1 < ngrams.len() { + RoaringBitmap::from_iter([1, 2]) + } else { + RoaringBitmap::from_iter([1]) + }; + let encoded = encode_ngram_bitmap(&documents).expect("encode ngram shard"); connection .execute( - "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (?1, ?2, 1)", - params![NGRAM_NORMALIZED, i64::from(*ngram)], + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (0, ?1, ?2, ?3, ?4)", + params![NGRAM_NORMALIZED, i64::from(*ngram), encoded, documents.len() as i64], ) .expect("complete phrase posting"); - if ordinal + 1 < ngrams.len() { - connection - .execute( - "INSERT INTO ngram_postings(kind, ngram, document_id) VALUES (?1, ?2, 2)", - params![NGRAM_NORMALIZED, i64::from(*ngram)], - ) - .expect("incomplete phrase posting"); - } } - let query = ngram_document_query(NGRAM_NORMALIZED, phrase); + let metrics = ArtifactQueryMetricsV1::default(); + let query = ngram_document_query(&connection, NGRAM_NORMALIZED, phrase, &metrics) + .expect("build ngram bitmap query"); + + assert_eq!(query.parameters.len(), 1); + assert_eq!(streamed_documents(&connection, &query), vec![1]); + } + + #[test] + fn ngram_bitmap_query_processes_rare_shards_first_and_short_circuits_common_work() { + let connection = Connection::open_in_memory().expect("in-memory SQLite"); + connection + .execute_batch( + "CREATE TABLE ngram_postings ( + page_ordinal INTEGER NOT NULL, + kind INTEGER NOT NULL, + ngram INTEGER NOT NULL, + documents BLOB NOT NULL, + cardinality INTEGER NOT NULL, + PRIMARY KEY(page_ordinal, kind, ngram) + ) WITHOUT ROWID; + CREATE UNIQUE INDEX ngram_postings_by_ngram ON ngram_postings(kind, ngram, page_ordinal);", + ) + .expect("ngram fixture schema"); + for (page_ordinal, ngram, documents) in [ + (0i64, 10u32, vec![1u32, 2]), + (1, 10, vec![3, 4]), + (2, 10, vec![5, 6]), + (0, 20, vec![2]), + (1, 30, vec![3]), + ] { + let bitmap = RoaringBitmap::from_iter(documents); + let encoded = encode_ngram_bitmap(&bitmap).expect("encode ngram shard"); + connection + .execute( + "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (?1, ?2, ?3, ?4, ?5)", + params![page_ordinal, NGRAM_NORMALIZED, i64::from(ngram), encoded, bitmap.len() as i64], + ) + .expect("seed ngram shard"); + } + + let bounded_metrics = ArtifactQueryMetricsV1::default(); + let matching = + ngram_bitmap_candidates(&connection, NGRAM_NORMALIZED, &[10, 20], &bounded_metrics) + .expect("intersect common and rare shards"); + assert_eq!(matching.iter().collect::>(), [2]); + assert_eq!(bounded_metrics.ngram_peak_candidates.get(), 1); + assert_eq!(bounded_metrics.ngram_decoded_shards.get(), 4); + let short_circuit_metrics = ArtifactQueryMetricsV1::default(); + let empty = ngram_bitmap_candidates( + &connection, + NGRAM_NORMALIZED, + &[10, 20, 30], + &short_circuit_metrics, + ) + .expect("short-circuit disjoint rare shards"); + assert!(empty.is_empty()); + assert_eq!(short_circuit_metrics.ngram_peak_candidates.get(), 1); assert_eq!( - query.parameters.len(), - ARTIFACT_NGRAM_INTERSECTION_SCRATCH_V1 + 2 + short_circuit_metrics.ngram_decoded_shards.get(), + 2, + "the three-page common ngram must not be decoded after rare shards empty the candidate set" + ); + } + + #[test] + fn ngram_candidate_json_honors_its_distinct_transient_byte_authority() { + let candidates = RoaringBitmap::from_iter([1, 20, 300]); + let exact = "[1,20,300]"; + assert_eq!( + encode_ngram_candidate_json(&candidates, exact.len()) + .expect("exact candidate JSON boundary"), + exact + ); + assert_eq!( + encode_ngram_candidate_json(&candidates, exact.len() - 1), + Err(crate::retrieval::ports::RetrievalPortError::BudgetExceeded) + ); + } + + #[test] + fn ngram_candidate_bitmap_honors_its_reader_memory_authority() { + assert_eq!( + ensure_ngram_candidate_cardinality(ARTIFACT_NGRAM_MAX_CANDIDATES_V1), + Ok(()) + ); + assert_eq!( + ensure_ngram_candidate_cardinality(ARTIFACT_NGRAM_MAX_CANDIDATES_V1 + 1), + Err(crate::retrieval::ports::RetrievalPortError::BudgetExceeded) + ); + } + + #[test] + fn ngram_query_rejects_cumulative_encoded_shards_past_its_authority() { + let mut remaining = 40usize; + for _ in 0..8 { + charge_ngram_encoded_shard_bytes(&mut remaining, 5, 5) + .expect("individually valid encoded shard"); + } + assert_eq!(remaining, 0); + assert_eq!( + charge_ngram_encoded_shard_bytes(&mut remaining, 1, 5), + Err(crate::retrieval::ports::RetrievalPortError::BudgetExceeded) + ); + assert_eq!( + remaining, 0, + "a refused shard must not consume the retained query authority" ); - assert_eq!(streamed_documents(&connection, &query), vec![1]); } #[test] @@ -2525,6 +2905,7 @@ mod tests { let documents = DocumentQueryV1 { sql: Some("SELECT ? AS document_id".to_owned()), parameters: vec![rusqlite::types::Value::Integer(1)], + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, }; let terms = (0..ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1) .map(|term| format!("term-{term}")) @@ -2551,6 +2932,7 @@ mod tests { let documents = DocumentQueryV1 { sql: Some("SELECT 1 AS document_id".to_owned()), parameters: Vec::new(), + maximum_bound_value_bytes: ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1, }; let per_term_bytes = ARTIFACT_SQLITE_MAX_BOUND_VALUE_BYTES_V1 / ARTIFACT_SQLITE_MAX_BIND_PARAMETERS_V1 + 1; diff --git a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs index 483aa2a611..4250b5e346 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -1144,6 +1144,68 @@ fn disk_artifact_resume_reopen_and_lexical_results_match_one_shot_projection() { assert_eq!(artifact, expected); } +#[test] +fn disk_artifact_batch_stores_one_ngram_bitmap_shard_per_distinct_key() { + let (fixture, pages, source_receipt) = real_verified_pages(); + let metadata = fixture.metadata.clone(); + let generation = metadata.generation.clone(); + let chunks = pages + .iter() + .flat_map(|page| page.chunks().iter().cloned()) + .collect::>(); + let one_shot = CodeLexicalProjectionAdapterV1::new_admitted(metadata.clone(), chunks) + .expect("one-shot lexical projection"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("ngram-bitmap-shards.sqlite"); + let control = ArtifactControl { cancelled: false }; + let mut builder = + CodeLexicalArtifactBuilderV1::create(&artifact_path, metadata).expect("create artifact"); + builder + .append_pages(&pages, &control) + .expect("commit one durable source batch"); + + let connection = rusqlite::Connection::open(&artifact_path).expect("inspect ngram shards"); + let (stored_rows, distinct_keys): (i64, i64) = connection + .query_row( + "SELECT COUNT(*), COUNT(DISTINCT printf('%d:%d', kind, ngram)) FROM ngram_postings", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("count durable ngram keys"); + assert!(stored_rows > 0, "the fixture must produce ngram candidates"); + assert_eq!( + stored_rows, distinct_keys, + "one atomic source batch must store one bitmap shard per distinct (kind, ngram), not one row per matching document" + ); + drop(connection); + + let verified = finish_staged_artifact(&mut builder, &source_receipt, &control); + let reader = CodeLexicalArtifactReaderV1::open_with_control( + &artifact_path, + &verified, + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, + &control, + ) + .expect("open finalized bitmap artifact"); + let mut request = lexical_request( + "rendre return value", + &["rendre"], + &[], + &["return value"], + 2, + 8, + ); + request.generation = generation; + assert_eq!( + LexicalLane::new(reader) + .retrieve_lexical(&request) + .expect("bitmap artifact lexical query"), + LexicalLane::new(one_shot) + .retrieve_lexical(&request) + .expect("one-shot lexical query") + ); +} + #[test] fn content_addressed_reader_rejects_atomic_same_size_replacement() { let (fixture, pages, source_receipt) = real_verified_pages(); @@ -2660,6 +2722,65 @@ fn disk_artifact_batch_ledger_charges_every_parallel_preparation_upper_bound() { } } +#[test] +fn disk_artifact_page_ledger_charges_live_ngram_map_and_encoded_shard_overlap() { + let fixture = real_lexical_source_fixture_from_sources(vec![( + "file.artifact".to_owned(), + "src/artifact.ts".to_owned(), + b"export functionabcdefghijklmnopqrstuvwxyz0123456789(value: string) { return value + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ9876543210'; }\n".to_vec(), + )]); + let (pages, _) = drain_verified_pages(&fixture, 128); + assert_eq!( + pages.len(), + 1, + "the adversarial fixture must occupy one page" + ); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let builder = CodeLexicalArtifactBuilderV1::create( + directory.path().join("ngram-overlap-ledger.sqlite"), + fixture.metadata, + ) + .expect("create artifact"); + let prepared = builder + .prepare_pages(&pages, &ArtifactControl { cancelled: false }) + .expect("prepare adversarial ngram page"); + let page = &pages[0]; + let prepared = &prepared[0]; + let logical_memberships = page + .chunks() + .iter() + .map(|chunk| { + chunk + .chunk() + .sanitized_text + .as_str() + .as_bytes() + .windows(3) + .collect::>() + .len() + }) + .sum::(); + let distinct_keys = page + .chunks() + .iter() + .flat_map(|chunk| chunk.chunk().sanitized_text.as_str().as_bytes().windows(3)) + .collect::>() + .len(); + // A distinct key owns an ordered-map node and one Roaring container; each + // membership owns sparse-container capacity while encoded output already + // accumulates. These deliberately conservative per-item bounds are below + // the production charge, but far above the unrelated one-document scratch. + let strict_live_map_lower_bound = distinct_keys + .checked_mul(64) + .and_then(|bytes| bytes.checked_add(logical_memberships.saturating_mul(128))) + .expect("ledger lower bound"); + assert!( + prepared.preparation_scratch_bytes() >= strict_live_map_lower_bound, + "aggregation scratch must coexist with encoded shards: charged={}, strict map lower bound={strict_live_map_lower_bound}, distinct_keys={distinct_keys}, memberships={logical_memberships}", + prepared.preparation_scratch_bytes(), + ); +} + #[test] fn disk_artifact_one_page_wrapper_matches_the_batch_path() { let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); @@ -2686,6 +2807,40 @@ fn disk_artifact_one_page_wrapper_matches_the_batch_path() { ); } +#[test] +fn disk_artifact_page_shards_have_batch_width_independent_receipts() { + let (fixture, pages, source_receipt) = real_verified_pages_with_maximum_page_chunks(1); + assert!( + pages.len() >= 2, + "fixture must exercise multiple source pages" + ); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let control = ArtifactControl { cancelled: false }; + let mut one_by_one = CodeLexicalArtifactBuilderV1::create( + directory.path().join("page-shards-one-by-one.sqlite"), + fixture.metadata.clone(), + ) + .expect("create one-page-width artifact"); + for page in &pages { + one_by_one + .append_page(page, &control) + .expect("append one source page"); + } + let mut batched = CodeLexicalArtifactBuilderV1::create( + directory.path().join("page-shards-batched.sqlite"), + fixture.metadata, + ) + .expect("create batched artifact"); + batched + .append_pages(&pages, &control) + .expect("append every source page atomically"); + + let one_by_one = finish_staged_artifact(&mut one_by_one, &source_receipt, &control); + let batched = finish_staged_artifact(&mut batched, &source_receipt, &control); + assert_eq!(one_by_one.artifact_digest(), batched.artifact_digest()); + assert_eq!(one_by_one.section_digests(), batched.section_digests()); +} + #[test] fn disk_artifact_budget_refusal_precedes_progress_and_accepts_boundary() { let (fixture, pages, source_receipt) = real_verified_pages_with_maximum_page_chunks(1); From f76ec8d114c90772500608f484313046795682b0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 17:44:50 +0000 Subject: [PATCH 55/69] fix(dashboard): keep polling until index freshness settles --- .../code/IndexFreshness.dom.test.tsx | 55 ++++++++++++++++ .../src/workspaces/code/IndexFreshness.tsx | 7 +- .../observatory/ObservatoryPage.dom.test.tsx | 64 +++++++++++++++++++ .../observatory/ObservatoryPage.tsx | 7 +- 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx index 79cdd0e827..c0dffc5b04 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.dom.test.tsx @@ -364,6 +364,61 @@ describe('Code index freshness', () => { expect(fetch).toHaveBeenCalledTimes(3); }); + it('keeps polling ready progress until the freshness envelope is ready', async () => { + vi.useFakeTimers(); + const readyProgress = { + ...progress(), + phase: 'ready', + completed_files: 500, + completed_lexical_bytes: 64 * 1024 * 1024, + estimated_remaining_seconds: 0, + }; + const transitioning = envelope('partial', { + worktrees: [ + { + ...worktree(), + latest_generation_id: readyProgress.generation_id, + staleness_state: 'stale', + coverage: 'partial', + progress: readyProgress, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const ready = envelope('ready', { + worktrees: [ + { + ...worktree(), + latest_generation_id: readyProgress.generation_id, + progress: readyProgress, + }, + ], + note: 'live daemon scheduler state; generation and scope come from the durable sealed generation', + }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(transitioning), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(ready), { status: 200 })); + vi.stubGlobal('fetch', fetch); + renderWith(); + + await advanceTimers(0); + expect(screen.getByText('Partial')).toBeTruthy(); + expect(screen.getByText('ready · 100.0%')).toBeTruthy(); + expect(fetch).toHaveBeenCalledTimes(1); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('Ready')).toBeTruthy(); + expect(screen.getByText('ready · 100.0%')).toBeTruthy(); + expect(fetch).toHaveBeenCalledTimes(2); + await advanceTimers(29_998); + expect(fetch).toHaveBeenCalledTimes(2); + await advanceTimers(1); + await advanceTimers(0); + expect(fetch).toHaveBeenCalledTimes(3); + }); + it('keeps a mounted ready worktree without progress on the 30-second cadence', async () => { vi.useFakeTimers(); const ready = envelope('ready', { diff --git a/dashboard/src/workspaces/code/IndexFreshness.tsx b/dashboard/src/workspaces/code/IndexFreshness.tsx index 0cd09a50d8..138b8b8c22 100644 --- a/dashboard/src/workspaces/code/IndexFreshness.tsx +++ b/dashboard/src/workspaces/code/IndexFreshness.tsx @@ -233,9 +233,10 @@ function BuildProgressReading({ function hasActiveBuild(result: EnvelopeResult | undefined): boolean { return ( result?.outcome === 'envelope' && - result.envelope.payload.worktrees.some( - (worktree) => worktree.progress != null && worktree.progress.phase !== 'ready', - ) + (result.envelope.domain_state !== 'ready' || + result.envelope.payload.worktrees.some( + (worktree) => worktree.progress != null && worktree.progress.phase !== 'ready', + )) ); } diff --git a/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx b/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx index 4dd5a09c0d..d907432613 100644 --- a/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx +++ b/dashboard/src/workspaces/observatory/ObservatoryPage.dom.test.tsx @@ -80,6 +80,70 @@ describe('ObservatoryPage store telemetry', () => { expect(text).toContain('last commit 240ms'); }); + it('keeps polling ready progress until the freshness envelope is ready', async () => { + vi.useFakeTimers(); + const building = codeIndexFreshnessEnvelope(); + const buildingWorktree = building.payload.worktrees[0]!; + const readyProgress = { + ...buildingWorktree.progress, + phase: 'ready', + completed_files: 500, + completed_lexical_bytes: 64 * 1024 * 1024, + estimated_remaining_seconds: 0, + }; + const transitioning = { + ...building, + domain_state: 'partial', + payload: { + ...building.payload, + worktrees: [ + { + ...buildingWorktree, + latest_generation_id: readyProgress.generation_id, + staleness_state: 'stale', + coverage: 'partial', + progress: readyProgress, + }, + ], + }, + }; + const authoritativeReady = readyCodeIndexFreshnessEnvelope(); + const ready = { + ...authoritativeReady, + payload: { + ...authoritativeReady.payload, + worktrees: [ + { + ...authoritativeReady.payload.worktrees[0]!, + latest_generation_id: readyProgress.generation_id, + progress: readyProgress, + }, + ], + }, + }; + const progressResponses = [transitioning, ready, ready]; + let progressResponse = 0; + stubTelemetry( + telemetryPayload(), + emptyStorageFindingsPayload(), + () => progressResponses[Math.min(progressResponse++, progressResponses.length - 1)]!, + ); + renderObservatory(); + + await advanceTimers(0); + expect(screen.getByText('ready · 100.0%')).toBeTruthy(); + expect(progressResponse).toBe(1); + await advanceTimers(1_001); + await advanceTimers(0); + expect(screen.getByText('ready · 100.0%')).toBeTruthy(); + expect(progressResponse).toBe(2); + await advanceTimers(29_998); + expect(progressResponse).toBe(2); + await advanceTimers(1); + await advanceTimers(0); + expect(progressResponse).toBe(3); + }); + it('accepts a later generation with a restarted epoch and rejects its delayed predecessor', async () => { vi.useFakeTimers(); const first = codeIndexFreshnessEnvelope(); diff --git a/dashboard/src/workspaces/observatory/ObservatoryPage.tsx b/dashboard/src/workspaces/observatory/ObservatoryPage.tsx index deda3920c7..70a7a0331e 100644 --- a/dashboard/src/workspaces/observatory/ObservatoryPage.tsx +++ b/dashboard/src/workspaces/observatory/ObservatoryPage.tsx @@ -292,9 +292,10 @@ function hasActiveCodeIndexBuild( ): boolean { return ( result?.outcome === 'envelope' && - result.envelope.payload.worktrees.some( - (worktree) => worktree.progress != null && worktree.progress.phase !== 'ready', - ) + (result.envelope.domain_state !== 'ready' || + result.envelope.payload.worktrees.some( + (worktree) => worktree.progress != null && worktree.progress.phase !== 'ready', + )) ); } From 636fd46466ce55ada230f2c1d38a6ea94029cbbf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 18:09:08 +0000 Subject: [PATCH 56/69] fix(index): satisfy callable query lint --- src/daemon/code_index_scheduler/queries.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/daemon/code_index_scheduler/queries.rs b/src/daemon/code_index_scheduler/queries.rs index eab2f476ac..0f95fa6203 100644 --- a/src/daemon/code_index_scheduler/queries.rs +++ b/src/daemon/code_index_scheduler/queries.rs @@ -422,20 +422,18 @@ impl CodeIndexSchedulerRegistryV1 { self.latest_text_fresh_for_scope(request.scope()) .await .ok_or(CallableCodeCursorError::Unavailable) + } else if let Some(latest) = self + .latest_text_serving_for_scope(request.scope()) + .await + .filter(|latest| latest.metadata().manifest().generation_id == *requested) + { + Ok(latest) } else { - if let Some(latest) = self - .latest_text_serving_for_scope(request.scope()) + self.generation_for(request.scope(), requested) .await - .filter(|latest| latest.metadata().manifest().generation_id == *requested) - { - Ok(latest) - } else { - self.generation_for(request.scope(), requested) - .await - .map_err(|_| CallableCodeCursorError::Unavailable)? - .map(|latest| latest.text_generation_handle()) - .ok_or(CallableCodeCursorError::Unavailable) - } + .map_err(|_| CallableCodeCursorError::Unavailable)? + .map(|latest| latest.text_generation_handle()) + .ok_or(CallableCodeCursorError::Unavailable) } }; let latest = tokio::time::timeout(wait, resolution) From 229e827c0de7fa8a715fece90bfc559c1855e1a7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 18:09:45 +0000 Subject: [PATCH 57/69] perf(query): adopt page receipts for artifact verification --- crates/tracedecay-query/Cargo.toml | 2 +- .../lexical/projection/artifact/builder.rs | 799 +++++++++--------- .../lexical/projection/artifact/format.rs | 300 ++++++- .../lexical/projection/artifact/prepared.rs | 102 ++- .../candidate_producers.rs | 210 ++--- 5 files changed, 896 insertions(+), 517 deletions(-) diff --git a/crates/tracedecay-query/Cargo.toml b/crates/tracedecay-query/Cargo.toml index cdfcb345d6..5cfa13d71f 100644 --- a/crates/tracedecay-query/Cargo.toml +++ b/crates/tracedecay-query/Cargo.toml @@ -15,7 +15,7 @@ hmac = "0.13.0" hotpath.workspace = true rayon = "1" roaring = { version = "=0.11.4", default-features = false } -rusqlite.workspace = true +rusqlite = { workspace = true, features = ["functions"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index e17c1ffdef..16cf65c5f2 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::time::Duration; use rayon::prelude::*; +use rusqlite::functions::FunctionFlags; use rusqlite::types::ValueRef; use rusqlite::{Connection, OptionalExtension, Transaction, params}; use serde::{Deserialize, Serialize}; @@ -27,11 +28,13 @@ use tracedecay_domain::{ use tracedecay_private_fs::{create_private_file_retained, open_private_file}; use super::format::{ - CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1, CodeLexicalArtifactSectionDigestV1, - RECEIPT_RESERVATION_BYTES, SECTION_NAMES, VerifiedCodeLexicalArtifactV1, artifact_digest, - decode_ngram_bitmap, decode_padded_receipt, decode_padded_receipt_with_control, encode_field, - metadata_digest, new_verified_receipt, ngram_page_digest, padded_receipt, - verify_artifact_table_layout, verify_required_artifact_indexes, + BASE_SECTION_NAMES, CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1, + CodeLexicalArtifactSectionDigestV1, RECEIPT_RESERVATION_BYTES, SECTION_NAMES, + VerifiedCodeLexicalArtifactV1, absorb_page_base_sections_receipt, artifact_digest, + decode_padded_receipt, decode_padded_receipt_with_control, encode_field, + finish_base_section_receipt_fold, initial_base_section_receipt_fold, metadata_digest, + new_verified_receipt, padded_receipt, verify_artifact_table_layout, + verify_required_artifact_indexes, }; use super::postings::document_ngram_scratch; use super::prepared::{PreparedCodeLexicalArtifactPageV1, prepare_page as prepare_page_values}; @@ -47,13 +50,184 @@ use crate::retrieval::lexical::LexicalFieldV1; use super::super::CodeLexicalProjectionMetadataV1; -const DOCUMENT_TERM_POSTINGS_QUERY: &str = "SELECT field, term, frequency FROM term_postings INDEXED BY term_postings_by_document WHERE document_id = ?1 ORDER BY field, term"; -const DOCUMENT_EXACT_POSTINGS_QUERY: &str = - "SELECT field, term FROM exact_postings WHERE document_id = ?1 ORDER BY field, term"; const PROGRESS_TAIL_QUERY: &str = "SELECT page_ordinal, import_dictionary_digest, cumulative_digest, next_cursor \ FROM source_pages ORDER BY page_ordinal DESC LIMIT 1"; const FINALIZATION_PROGRESS_INTERVAL_OPS: i32 = 4_096; const FINALIZATION_CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(1); +// This gate serializes mutation within the private-profile/stable-handle +// authority. It denies ordinary second-connection DML, but is not a +// cryptographic defense against malicious same-UID code that deliberately +// registers a lookalike SQLite function. +const BUILDER_MUTATION_GATE_FUNCTION: &str = "tracedecay_lexical_builder_append_authorized"; +const BUILDER_MUTATION_IDLE: u8 = 0; +const BUILDER_MUTATION_APPEND: u8 = 1; +const BUILDER_GATE_TRIGGER_LAYOUT: [(&str, &str, &str); 14] = [ + ("builder_gate_source_pages_insert", "source_pages", "INSERT"), + ( + "builder_gate_document_integrity_insert", + "document_integrity", + "INSERT", + ), + ( + "builder_gate_import_integrity_insert", + "import_integrity", + "INSERT", + ), + ( + "builder_gate_import_evidence_insert", + "import_evidence", + "INSERT", + ), + ("builder_gate_rows_insert", "rows", "INSERT"), + ("builder_gate_rows_update", "rows", "UPDATE"), + ("builder_gate_rows_delete", "rows", "DELETE"), + ( + "builder_gate_term_postings_insert", + "term_postings", + "INSERT", + ), + ( + "builder_gate_term_postings_update", + "term_postings", + "UPDATE", + ), + ( + "builder_gate_term_postings_delete", + "term_postings", + "DELETE", + ), + ( + "builder_gate_exact_postings_insert", + "exact_postings", + "INSERT", + ), + ( + "builder_gate_exact_postings_update", + "exact_postings", + "UPDATE", + ), + ( + "builder_gate_exact_postings_delete", + "exact_postings", + "DELETE", + ), + ( + "builder_gate_ngram_postings_insert", + "ngram_postings", + "INSERT", + ), +]; +const IMMUTABLE_TRIGGER_LAYOUT: [(&str, &str, &str, &str); 10] = [ + ( + "immutable_source_pages_update", + "source_pages", + "UPDATE", + "immutable lexical source pages", + ), + ( + "immutable_source_pages_delete", + "source_pages", + "DELETE", + "immutable lexical source pages", + ), + ( + "immutable_document_integrity_update", + "document_integrity", + "UPDATE", + "immutable lexical document integrity", + ), + ( + "immutable_document_integrity_delete", + "document_integrity", + "DELETE", + "immutable lexical document integrity", + ), + ( + "immutable_import_integrity_update", + "import_integrity", + "UPDATE", + "immutable lexical import integrity", + ), + ( + "immutable_import_integrity_delete", + "import_integrity", + "DELETE", + "immutable lexical import integrity", + ), + ( + "immutable_import_evidence_update", + "import_evidence", + "UPDATE", + "immutable lexical import evidence", + ), + ( + "immutable_import_evidence_delete", + "import_evidence", + "DELETE", + "immutable lexical import evidence", + ), + ( + "immutable_ngram_postings_update", + "ngram_postings", + "UPDATE", + "immutable lexical ngram postings", + ), + ( + "immutable_ngram_postings_delete", + "ngram_postings", + "DELETE", + "immutable lexical ngram postings", + ), +]; + +struct BuilderMutationGuardV1 { + gate: Arc, +} + +impl BuilderMutationGuardV1 { + fn enter(gate: &Arc) -> Result { + gate.compare_exchange( + BUILDER_MUTATION_IDLE, + BUILDER_MUTATION_APPEND, + Ordering::AcqRel, + Ordering::Acquire, + ) + .map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact builder mutation authority is already active".to_owned(), + ) + })?; + Ok(Self { + gate: Arc::clone(gate), + }) + } +} + +impl Drop for BuilderMutationGuardV1 { + fn drop(&mut self) { + self.gate.store(BUILDER_MUTATION_IDLE, Ordering::Release); + } +} + +fn register_builder_mutation_gate( + connection: &Connection, +) -> Result, CodeLexicalArtifactErrorV1> { + let gate = Arc::new(AtomicU8::new(BUILDER_MUTATION_IDLE)); + let function_gate = Arc::clone(&gate); + connection + .create_scalar_function( + BUILDER_MUTATION_GATE_FUNCTION, + 0, + FunctionFlags::SQLITE_UTF8, + move |_| { + Ok(i64::from( + function_gate.load(Ordering::Acquire) == BUILDER_MUTATION_APPEND, + )) + }, + ) + .map_err(sqlite_error)?; + Ok(gate) +} #[cfg(test)] std::thread_local! { @@ -122,7 +296,7 @@ impl FinalizationSectionV1 { const fn full_query(self) -> &'static str { match self { Self::SourcePages => { - "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, next_cursor FROM source_pages ORDER BY page_ordinal" + "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, base_sections_receipt, next_cursor FROM source_pages ORDER BY page_ordinal" } Self::DocumentIntegrity => { "SELECT document_id, chunk_id, digest FROM document_integrity ORDER BY document_id" @@ -155,10 +329,10 @@ impl FinalizationSectionV1 { const fn seek_query(self, after: bool) -> &'static str { match (self, after) { (Self::SourcePages, false) => { - "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, next_cursor FROM source_pages ORDER BY page_ordinal LIMIT ?1" + "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, base_sections_receipt, next_cursor FROM source_pages ORDER BY page_ordinal LIMIT ?1" } (Self::SourcePages, true) => { - "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, next_cursor FROM source_pages WHERE page_ordinal > ?1 ORDER BY page_ordinal LIMIT ?2" + "SELECT page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, base_sections_receipt, next_cursor FROM source_pages WHERE page_ordinal > ?1 ORDER BY page_ordinal LIMIT ?2" } (Self::DocumentIntegrity, false) => { "SELECT document_id, chunk_id, digest FROM document_integrity ORDER BY document_id LIMIT ?1" @@ -319,6 +493,8 @@ struct PersistedFinalizationStateV1 { section_row_count: u64, section_last_key: Option, section_accumulator: Vec, + base_section_row_counts: Vec, + base_section_accumulators: Vec>, completed_sections: Vec, completed_rows: u64, content_epoch: i64, @@ -510,6 +686,7 @@ pub struct CodeLexicalArtifactBuilderV1 { _private_file: File, file_identity: StableArtifactFileIdentityV1, connection: Connection, + mutation_gate: Arc, metadata: CodeLexicalProjectionMetadataV1, metadata_digest: ManifestDigest, memory_budget_bytes: usize, @@ -564,7 +741,9 @@ impl CodeLexicalArtifactBuilderV1 { )); } let (connection, private_file, file_identity) = create_private_builder_connection(path)?; + let mutation_gate = register_builder_mutation_gate(&connection)?; create_schema(&connection)?; + verify_builder_mutation_gate_schema(&connection)?; let metadata_digest = metadata_digest(&metadata)?; let metadata_bytes = serde_json::to_vec(&metadata) .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; @@ -585,6 +764,7 @@ impl CodeLexicalArtifactBuilderV1 { _private_file: private_file, file_identity, connection, + mutation_gate, metadata, metadata_digest, memory_budget_bytes, @@ -610,10 +790,12 @@ impl CodeLexicalArtifactBuilderV1 { validated_fixed_ledger_charge(&expected_metadata, memory_budget_bytes)?; let path = path.as_ref(); let (connection, private_file, file_identity) = open_private_builder_connection(path)?; + let mutation_gate = register_builder_mutation_gate(&connection)?; require_integrity(&connection, control)?; let expected_digest = metadata_digest(&expected_metadata)?; verify_artifact_state_metadata(&connection, &expected_metadata, &expected_digest, control)?; verify_artifact_table_layout(&connection)?; + verify_builder_mutation_gate_schema(&connection)?; let receipt = read_receipt_with_control(&connection, control)?; let finalization = load_finalization_state(&connection)?; if receipt.is_some() @@ -631,6 +813,7 @@ impl CodeLexicalArtifactBuilderV1 { _private_file: private_file, file_identity, connection, + mutation_gate, metadata: expected_metadata, metadata_digest: expected_digest, memory_budget_bytes, @@ -984,6 +1167,7 @@ impl CodeLexicalArtifactBuilderV1 { pages, )?; hotpath::measure_block!("query.artifact.batch.sqlite", { + let _mutation_authority = BuilderMutationGuardV1::enter(&self.mutation_gate)?; let transaction = self.connection.transaction().map_err(sqlite_error)?; let mutation = (|| { hotpath::measure_block!("query.artifact.batch.imports", { @@ -1163,11 +1347,31 @@ impl CodeLexicalArtifactBuilderV1 { let section_digest = finish_persisted_section(section_name, &state)?; state.completed_sections.push(section_digest); - state.section_ordinal = state.section_ordinal.checked_add(1).ok_or_else(|| { - CodeLexicalArtifactErrorV1::Contract( - "lexical artifact finalization section ordinal overflowed".to_owned(), - ) - })?; + if section == FinalizationSectionV1::SourcePages { + let base_sections = finish_base_section_receipt_fold( + &state.base_section_row_counts, + &state.base_section_accumulators, + )?; + state.completed_rows = base_sections + .iter() + .try_fold(state.completed_rows, |total, section| { + total.checked_add(section.row_count) + }) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact adopted base-section row count overflowed".to_owned(), + ) + })?; + state.completed_sections.extend(base_sections); + state.section_ordinal = + u64::try_from(1 + BASE_SECTION_NAMES.len()).map_err(contract_number)?; + } else { + state.section_ordinal = state.section_ordinal.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact finalization section ordinal overflowed".to_owned(), + ) + })?; + } state.section_row_count = 0; state.section_last_key = None; if state.section_ordinal < section_count { @@ -2290,7 +2494,7 @@ fn insert_prepared_source_page( ) -> Result<(), CodeLexicalArtifactErrorV1> { transaction .execute( - "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, next_cursor) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, base_sections_receipt, next_cursor) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ i64::try_from(page.page_ordinal).map_err(contract_number)?, page.page_digest.as_str(), @@ -2301,6 +2505,7 @@ fn insert_prepared_source_page( i64::try_from(page.import_payload_bytes).map_err(contract_number)?, page.import_dictionary_digest.as_str(), page.ngram_digest.as_str(), + page.base_sections_receipt.as_slice(), page.next_cursor.as_slice(), ], ) @@ -2360,12 +2565,12 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError import_payload_bytes INTEGER NOT NULL, import_dictionary_digest TEXT NOT NULL, ngram_digest TEXT NOT NULL, + base_sections_receipt BLOB NOT NULL, next_cursor BLOB NOT NULL ); -- Every derived document and import receives its digest in the - -- same transaction that admits it. Bounded finalization verifies - -- these receipts before it seals a self-contained artifact, so a - -- pre-seal mutation cannot attest itself without rereading source. + -- same private append transaction as its page-level base receipt. + -- External connections cannot invoke that mutation authority. CREATE TABLE document_integrity ( document_id INTEGER PRIMARY KEY, chunk_id TEXT NOT NULL, @@ -2430,11 +2635,69 @@ fn create_schema(connection: &Connection) -> Result<(), CodeLexicalArtifactError CREATE TRIGGER immutable_import_evidence_delete BEFORE DELETE ON import_evidence BEGIN SELECT RAISE(ABORT, 'immutable lexical import evidence'); END; CREATE TRIGGER immutable_ngram_postings_update BEFORE UPDATE ON ngram_postings BEGIN SELECT RAISE(ABORT, 'immutable lexical ngram postings'); END; CREATE TRIGGER immutable_ngram_postings_delete BEFORE DELETE ON ngram_postings BEGIN SELECT RAISE(ABORT, 'immutable lexical ngram postings'); END; + CREATE TRIGGER builder_gate_source_pages_insert BEFORE INSERT ON source_pages WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_document_integrity_insert BEFORE INSERT ON document_integrity WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_import_integrity_insert BEFORE INSERT ON import_integrity WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_import_evidence_insert BEFORE INSERT ON import_evidence WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_rows_insert BEFORE INSERT ON rows WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_rows_update BEFORE UPDATE ON rows WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_rows_delete BEFORE DELETE ON rows WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_term_postings_insert BEFORE INSERT ON term_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_term_postings_update BEFORE UPDATE ON term_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_term_postings_delete BEFORE DELETE ON term_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_exact_postings_insert BEFORE INSERT ON exact_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_exact_postings_update BEFORE UPDATE ON exact_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_exact_postings_delete BEFORE DELETE ON exact_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; + CREATE TRIGGER builder_gate_ngram_postings_insert BEFORE INSERT ON ngram_postings WHEN tracedecay_lexical_builder_append_authorized() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END; ", ) .map_err(sqlite_error) } +fn verify_builder_mutation_gate_schema( + connection: &Connection, +) -> Result<(), CodeLexicalArtifactErrorV1> { + for (name, table, operation) in BUILDER_GATE_TRIGGER_LAYOUT { + let expected = format!( + "CREATE TRIGGER {name} BEFORE {operation} ON {table} WHEN {BUILDER_MUTATION_GATE_FUNCTION}() != 1 BEGIN SELECT RAISE(ABORT, 'private lexical builder mutation required'); END" + ); + verify_trigger_schema(connection, name, table, &expected)?; + } + for (name, table, operation, message) in IMMUTABLE_TRIGGER_LAYOUT { + let expected = format!( + "CREATE TRIGGER {name} BEFORE {operation} ON {table} BEGIN SELECT RAISE(ABORT, '{message}'); END" + ); + verify_trigger_schema(connection, name, table, &expected)?; + } + Ok(()) +} + +fn verify_trigger_schema( + connection: &Connection, + name: &str, + expected_table: &str, + expected_sql: &str, +) -> Result<(), CodeLexicalArtifactErrorV1> { + let stored: Option<(String, String)> = connection + .query_row( + "SELECT tbl_name, sql FROM sqlite_schema WHERE type = 'trigger' AND name = ?1", + [name], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(sqlite_corrupt)?; + if stored + .as_ref() + .map(|(table, sql)| (table.as_str(), sql.as_str())) + != Some((expected_table, expected_sql)) + { + return Err(CodeLexicalArtifactErrorV1::Corrupt(format!( + "lexical artifact private builder trigger {name} is missing or malformed" + ))); + } + Ok(()) +} + fn install_base_freeze(transaction: &Transaction<'_>) -> Result<(), CodeLexicalArtifactErrorV1> { transaction .execute_batch( @@ -2464,6 +2727,7 @@ fn authenticated_authority_epoch( transaction: &Transaction<'_>, source: &VerifiedSealedLexicalSourceReceiptV1, ) -> Result { + verify_builder_mutation_gate_schema(transaction)?; let (pages, documents, import_integrity, import_evidence): (i64, i64, i64, i64) = transaction .query_row( "SELECT (SELECT COUNT(*) FROM source_pages), (SELECT COUNT(*) FROM document_integrity), (SELECT COUNT(*) FROM import_integrity), (SELECT COUNT(*) FROM import_evidence)", @@ -2491,31 +2755,6 @@ fn authenticated_authority_epoch( "lexical artifact authenticated authority disagrees with its source receipt".to_owned(), )); } - let orphaned: i64 = transaction - .query_row( - "SELECT EXISTS( - SELECT 1 FROM rows AS content LEFT JOIN document_integrity AS authority USING(document_id) WHERE authority.document_id IS NULL - UNION ALL SELECT 1 FROM term_postings AS content LEFT JOIN document_integrity AS authority USING(document_id) WHERE authority.document_id IS NULL - UNION ALL SELECT 1 FROM exact_postings AS content LEFT JOIN document_integrity AS authority USING(document_id) WHERE authority.document_id IS NULL - UNION ALL SELECT 1 FROM ngram_postings AS content LEFT JOIN source_pages AS authority USING(page_ordinal) WHERE authority.page_ordinal IS NULL - )", - [], - |row| row.get(0), - ) - .map_err(sqlite_error)?; - let mismatched_rows: i64 = transaction - .query_row( - "SELECT EXISTS(SELECT 1 FROM document_integrity AS authority LEFT JOIN rows AS content USING(document_id) WHERE content.document_id IS NULL OR content.chunk_id != authority.chunk_id)", - [], - |row| row.get(0), - ) - .map_err(sqlite_error)?; - if orphaned != 0 || mismatched_rows != 0 { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact base rows are not covered by authenticated document authority" - .to_owned(), - )); - } Ok(actual_epoch) } @@ -2792,12 +3031,16 @@ impl PersistedFinalizationStateV1 { "lexical artifact mutation epoch is negative".to_owned(), )); } + let (base_section_row_counts, base_section_accumulators) = + initial_base_section_receipt_fold()?; Ok(Self { phase: PersistedFinalizationPhaseV1::Statistics, section_ordinal: 0, section_row_count: 0, section_last_key: None, section_accumulator: initial_section_accumulator(SECTION_NAMES[0])?.to_vec(), + base_section_row_counts, + base_section_accumulators, completed_sections: Vec::new(), completed_rows: 0, content_epoch, @@ -2927,9 +3170,19 @@ fn validate_finalization_state( PersistedFinalizationPhaseV1::Digest => section_count, }; if state.section_ordinal > maximum_ordinal + || (state.phase == PersistedFinalizationPhaseV1::Digest + && state.section_ordinal > 0 + && state.section_ordinal + < u64::try_from(1 + BASE_SECTION_NAMES.len()).map_err(contract_number)?) || state.completed_sections.len() != completed_section_count || state.completed_sections.len() > SECTION_NAMES.len() || state.section_accumulator.len() != 32 + || state.base_section_row_counts.len() != BASE_SECTION_NAMES.len() + || state.base_section_accumulators.len() != BASE_SECTION_NAMES.len() + || state + .base_section_accumulators + .iter() + .any(|accumulator| accumulator.len() != 32) || state.content_epoch < 0 { return Err(CodeLexicalArtifactErrorV1::Corrupt( @@ -3127,13 +3380,20 @@ fn advance_native_section_rows( "lexical artifact finalization keyset did not advance".to_owned(), )); } - if matches!( - section, - FinalizationSectionV1::SourcePages - | FinalizationSectionV1::DocumentIntegrity - | FinalizationSectionV1::ImportIntegrity - ) { - verify_integrity_row(transaction, section, row, control)?; + if section == FinalizationSectionV1::SourcePages { + let page_ordinal = + u64::try_from(row.get::<_, i64>(0).map_err(sqlite_error)?).map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt has a negative page".to_owned(), + ) + })?; + let receipt: Vec = row.get(9).map_err(sqlite_error)?; + absorb_page_base_sections_receipt( + page_ordinal, + &receipt, + &mut state.base_section_row_counts, + &mut state.base_section_accumulators, + )?; } absorb_section_row( section.name(), @@ -3202,81 +3462,6 @@ fn native_row_key( } } -fn verify_integrity_row( - transaction: &Transaction<'_>, - section: FinalizationSectionV1, - row: &rusqlite::Row<'_>, - control: &dyn CodeIndexExecutionControlV1, -) -> Result<(), CodeLexicalArtifactErrorV1> { - let expected_column = match section { - FinalizationSectionV1::SourcePages => 8, - FinalizationSectionV1::DocumentIntegrity => 2, - FinalizationSectionV1::ImportIntegrity => 1, - _ => { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact integrity verification selected the wrong section".to_owned(), - )); - } - }; - let expected: String = row.get(expected_column).map_err(sqlite_error)?; - let actual = match section { - FinalizationSectionV1::SourcePages => match row.get_ref(0).map_err(sqlite_error)? { - ValueRef::Integer(page_ordinal) if page_ordinal >= 0 => { - ngram_page_integrity_digest(transaction, page_ordinal, control)? - } - _ => { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact source-page ngram receipt has an invalid key".to_owned(), - )); - } - }, - FinalizationSectionV1::DocumentIntegrity => match row.get_ref(0).map_err(sqlite_error)? { - ValueRef::Integer(document) if document >= 0 => { - document_integrity_digest(transaction, document, control)? - } - _ => { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact document integrity receipt has an invalid key".to_owned(), - )); - } - }, - FinalizationSectionV1::ImportIntegrity => match row.get_ref(0).map_err(sqlite_error)? { - ValueRef::Blob(canonical) => { - let evidence: Option> = transaction - .query_row( - "SELECT evidence FROM import_evidence WHERE canonical = ?1", - [canonical], - |row| row.get(0), - ) - .optional() - .map_err(sqlite_error)?; - let evidence = evidence.ok_or_else(|| { - CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact import integrity receipt has no evidence".to_owned(), - ) - })?; - import_integrity_digest(canonical, &evidence)? - } - _ => { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact import integrity receipt has an invalid key".to_owned(), - )); - } - }, - _ => { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact integrity verification selected the wrong section".to_owned(), - )); - } - }; - if actual.as_str() != expected { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact derived content differs from its append receipt".to_owned(), - )); - } - Ok(()) -} - fn initial_section_accumulator(name: &str) -> Result<[u8; 32], CodeLexicalArtifactErrorV1> { let mut hasher = Sha256::new(); hasher.update(b"tracedecay.code-lexical-artifact-section.v2\0initial"); @@ -3476,162 +3661,6 @@ fn verify_final_sections_against_source( Ok(()) } -fn document_integrity_digest( - transaction: &Transaction<'_>, - document: i64, - control: &dyn CodeIndexExecutionControlV1, -) -> Result { - checkpoint(control)?; - let mut hasher = Sha256::new(); - hasher.update(b"tracedecay.code-lexical-artifact-derived-document.v3\0"); - hasher.update(document.to_le_bytes()); - let row_count = hash_document_table( - transaction, - &mut hasher, - "row", - "SELECT chunk_id, row FROM rows WHERE document_id = ?1", - document, - control, - )?; - if row_count != 1 { - return Err(CodeLexicalArtifactErrorV1::Corrupt(format!( - "lexical artifact document {document} is missing its derived row" - ))); - } - hash_document_table( - transaction, - &mut hasher, - "term_posting", - DOCUMENT_TERM_POSTINGS_QUERY, - document, - control, - )?; - hash_document_table( - transaction, - &mut hasher, - "exact_posting", - DOCUMENT_EXACT_POSTINGS_QUERY, - document, - control, - )?; - integrity_digest(hasher) -} - -fn ngram_page_integrity_digest( - transaction: &Transaction<'_>, - page_ordinal: i64, - control: &dyn CodeIndexExecutionControlV1, -) -> Result { - checkpoint(control)?; - let (chunk_count, cursor_bytes): (i64, Vec) = transaction - .query_row( - "SELECT chunk_count, next_cursor FROM source_pages WHERE page_ordinal = ?1", - [page_ordinal], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .map_err(sqlite_error)?; - let chunk_count = u64::try_from(chunk_count).map_err(contract_number)?; - let cursor = decode_cursor(&cursor_bytes)?; - let first_document = cursor - .emitted_chunks() - .checked_sub(chunk_count) - .ok_or_else(|| { - CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact ngram page cursor regressed its chunk count".to_owned(), - ) - })?; - let final_document = cursor.emitted_chunks(); - let mut statement = transaction - .prepare( - "SELECT kind, ngram, documents, cardinality FROM ngram_postings WHERE page_ordinal = ?1 ORDER BY kind, ngram", - ) - .map_err(sqlite_error)?; - let mut query = statement.query([page_ordinal]).map_err(sqlite_error)?; - let mut shards = Vec::<(i64, i64, Vec, u64)>::new(); - while let Some(row) = query.next().map_err(sqlite_error)? { - checkpoint(control)?; - let kind = row.get(0).map_err(sqlite_error)?; - let ngram = row.get(1).map_err(sqlite_error)?; - let documents: Vec = row.get(2).map_err(sqlite_error)?; - let cardinality: i64 = row.get(3).map_err(sqlite_error)?; - let bitmap = decode_ngram_bitmap(&documents)?; - if i64::try_from(bitmap.len()).map_err(contract_number)? != cardinality - || bitmap.iter().any(|document| { - let document = u64::from(document); - document < first_document || document >= final_document - }) - { - return Err(CodeLexicalArtifactErrorV1::Corrupt( - "lexical artifact ngram bitmap disagrees with its source page".to_owned(), - )); - } - shards.push(( - kind, - ngram, - documents, - u64::try_from(cardinality).map_err(contract_number)?, - )); - } - ngram_page_digest( - u64::try_from(page_ordinal).map_err(contract_number)?, - shards.iter().map(|(kind, ngram, documents, cardinality)| { - (*kind, *ngram, documents.as_slice(), *cardinality) - }), - ) -} - -fn hash_document_table( - transaction: &Transaction<'_>, - hasher: &mut Sha256, - table: &str, - query: &str, - document: i64, - control: &dyn CodeIndexExecutionControlV1, -) -> Result { - checkpoint(control)?; - hasher.update( - u64::try_from(table.len()) - .map_err(contract_number)? - .to_le_bytes(), - ); - hasher.update(table.as_bytes()); - let mut statement = transaction.prepare(query).map_err(sqlite_error)?; - let column_count = statement.column_count(); - let mut rows = statement.query([document]).map_err(sqlite_error)?; - let mut count = 0u64; - while let Some(row) = rows.next().map_err(sqlite_error)? { - checkpoint(control)?; - hasher.update(b"row\0"); - for column in 0..column_count { - hash_value(hasher, row.get_ref(column).map_err(sqlite_error)?)?; - } - count = count.checked_add(1).ok_or_else(|| { - CodeLexicalArtifactErrorV1::Contract( - "lexical artifact document integrity row count overflowed".to_owned(), - ) - })?; - } - hasher.update(b"end\0"); - hasher.update(count.to_le_bytes()); - Ok(count) -} - -fn import_integrity_digest( - canonical: &[u8], - evidence: &[u8], -) -> Result { - let mut hasher = Sha256::new(); - hasher.update(b"tracedecay.code-lexical-artifact-derived-import.v1\0"); - hash_bytes(&mut hasher, canonical)?; - hash_bytes(&mut hasher, evidence)?; - integrity_digest(hasher) -} - -fn integrity_digest(hasher: Sha256) -> Result { - ManifestDigest::from_sha256_bytes(&hasher.finalize()) - .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string())) -} - /// One staged `source_pages` receipt row: page and cumulative digests, chunk /// and payload counts, import counts, dictionary digest, and cursor bytes. type StoredSourcePageRowV1 = (String, String, i64, i64, i64, i64, String, Vec); @@ -3795,10 +3824,71 @@ pub(super) fn compute_section_digests( connection: &Connection, control: &dyn CodeIndexExecutionControlV1, ) -> Result, CodeLexicalArtifactErrorV1> { - FinalizationSectionV1::ALL - .into_iter() - .map(|section| digest_query(connection, section, control)) - .collect() + let (source_pages, base_sections) = digest_source_pages_and_base_receipts(connection, control)?; + let mut sections = Vec::with_capacity(SECTION_NAMES.len()); + sections.push(source_pages); + sections.extend(base_sections); + for section in [ + FinalizationSectionV1::FieldStatistics, + FinalizationSectionV1::TermStatistics, + FinalizationSectionV1::Vocabulary, + ] { + sections.push(digest_query(connection, section, control)?); + } + Ok(sections) +} + +fn digest_source_pages_and_base_receipts( + connection: &Connection, + control: &dyn CodeIndexExecutionControlV1, +) -> Result< + ( + CodeLexicalArtifactSectionDigestV1, + Vec, + ), + CodeLexicalArtifactErrorV1, +> { + let section = FinalizationSectionV1::SourcePages; + let mut row_count = 0u64; + let mut accumulator = initial_section_accumulator(section.name())?.to_vec(); + let (mut base_row_counts, mut base_accumulators) = initial_base_section_receipt_fold()?; + let mut statement = connection + .prepare(section.full_query()) + .map_err(sqlite_error)?; + let column_count = statement.column_count(); + let mut rows = statement.query([]).map_err(sqlite_error)?; + while let Some(row) = rows.next().map_err(sqlite_error)? { + checkpoint(control)?; + let page_ordinal = + u64::try_from(row.get::<_, i64>(0).map_err(sqlite_error)?).map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt has a negative page".to_owned(), + ) + })?; + let receipt: Vec = row.get(9).map_err(sqlite_error)?; + absorb_page_base_sections_receipt( + page_ordinal, + &receipt, + &mut base_row_counts, + &mut base_accumulators, + )?; + absorb_section_row( + section.name(), + row_count, + &mut accumulator, + row, + column_count, + )?; + row_count = row_count.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact source-page receipt count overflowed".to_owned(), + ) + })?; + } + Ok(( + finish_section(section.name(), row_count, &accumulator)?, + finish_base_section_receipt_fold(&base_row_counts, &base_accumulators)?, + )) } fn digest_query( @@ -4196,7 +4286,7 @@ mod tests { use super::*; use roaring::RoaringBitmap; use rusqlite::StatementStatus; - use std::sync::atomic::{AtomicUsize, Ordering}; + use rusqlite::hooks::{AuthAction, Authorization}; use tracedecay_domain::{ CodeGenerationId, ComponentRevision, FreshnessCompatibilityV1, ScoreDomainId, SourceFreshness, SourceInstanceKey, SourceNamespace, UtcMicros, @@ -4214,11 +4304,6 @@ mod tests { } } - struct CancelAtCheckpoint { - checkpoint: usize, - observations: AtomicUsize, - } - fn test_metadata() -> CodeLexicalProjectionMetadataV1 { CodeLexicalProjectionMetadataV1 { generation: CodeGenerationId::new("generation.artifact-builder.v1") @@ -4248,14 +4333,11 @@ mod tests { } } - impl CodeIndexExecutionControlV1 for CancelAtCheckpoint { - fn is_cancelled(&self) -> bool { - self.observations.fetch_add(1, Ordering::SeqCst) + 1 >= self.checkpoint - } - - fn is_deadline_exceeded(&self) -> bool { - false - } + fn create_mutable_test_schema(connection: &Connection) -> BuilderMutationGuardV1 { + let gate = + register_builder_mutation_gate(connection).expect("register builder mutation gate"); + create_schema(connection).expect("create artifact schema"); + BuilderMutationGuardV1::enter(&gate).expect("enter test builder mutation authority") } #[test] @@ -4309,10 +4391,38 @@ mod tests { ); } + #[test] + fn receipt_verification_does_not_read_append_only_base_tables() { + let connection = Connection::open_in_memory().expect("open artifact database"); + let _mutation_authority = create_mutable_test_schema(&connection); + connection + .authorizer(Some( + |context: rusqlite::hooks::AuthContext<'_>| match context.action { + AuthAction::Read { table_name, .. } + if BASE_SECTION_NAMES.contains(&table_name) => + { + Authorization::Deny + } + _ => Authorization::Allow, + }, + )) + .expect("deny exhaustive base-table verification reads"); + + let sections = compute_section_digests(&connection, &ActiveControl) + .expect("verify only source-page receipts and derived sections"); + assert_eq!( + sections + .iter() + .map(|section| section.name.as_str()) + .collect::>(), + SECTION_NAMES + ); + } + #[test] fn finalization_monitor_spawn_failure_is_typed_and_leaves_sqlite_reusable() { let mut connection = Connection::open_in_memory().expect("open artifact database"); - create_schema(&connection).expect("create artifact schema"); + let _mutation_authority = create_mutable_test_schema(&connection); let transaction = connection.transaction().expect("start transaction"); fail_next_finalization_monitor_spawn(); let operation_ran = AtomicBool::new(false); @@ -4337,7 +4447,7 @@ mod tests { #[test] fn ngram_staging_key_preserves_source_page_order_without_a_serving_index() { let connection = Connection::open_in_memory().expect("open artifact database"); - create_schema(&connection).expect("create artifact schema"); + let _mutation_authority = create_mutable_test_schema(&connection); for (page_ordinal, ngram, document) in [(0i64, 90i64, 0u32), (0, 100, 0), (1, 10, 1), (1, 20, 1)] { @@ -4390,7 +4500,7 @@ mod tests { #[test] fn deferred_ngram_serving_index_is_unique_and_query_selective() { let mut connection = Connection::open_in_memory().expect("open artifact database"); - create_schema(&connection).expect("create artifact schema"); + let _mutation_authority = create_mutable_test_schema(&connection); for (page_ordinal, ngram, documents) in [ (0i64, 10i64, vec![1u32, 2]), (0, 20, vec![1]), @@ -4540,7 +4650,7 @@ mod tests { #[test] fn one_document_integrity_row_does_not_visit_unrelated_relational_rows() { let mut connection = Connection::open_in_memory().expect("open artifact database"); - create_schema(&connection).expect("create artifact schema"); + let _mutation_authority = create_mutable_test_schema(&connection); let transaction = connection.transaction().expect("start seed transaction"); for document_id in 0..2_048i64 { transaction @@ -4566,7 +4676,10 @@ mod tests { } transaction.commit().expect("commit integrity index"); - for query in [DOCUMENT_TERM_POSTINGS_QUERY, DOCUMENT_EXACT_POSTINGS_QUERY] { + for query in [ + "SELECT field, term, frequency FROM term_postings INDEXED BY term_postings_by_document WHERE document_id = ?1 ORDER BY field, term", + "SELECT field, term FROM exact_postings WHERE document_id = ?1 ORDER BY field, term", + ] { let mut statement = connection.prepare(query).expect("prepare integrity query"); { let mut rows = statement.query([1_024i64]).expect("query one document"); @@ -4586,83 +4699,13 @@ mod tests { } } - #[test] - fn document_integrity_hashing_cancels_without_advancing_finalization() { - let mut connection = Connection::open_in_memory().expect("open artifact database"); - create_schema(&connection).expect("create artifact schema"); - let transaction = connection - .transaction() - .expect("start artifact transaction"); - transaction - .execute( - "INSERT INTO rows(document_id, chunk_id, row) VALUES (0, 'chunk', X'01')", - [], - ) - .expect("seed row"); - for ordinal in 0..64i64 { - transaction - .execute( - "INSERT INTO term_postings(field, term, document_id, frequency) VALUES ('body', ?1, 0, 1)", - [format!("term-{ordinal:02}")], - ) - .expect("seed term posting"); - } - build_serving_index_step(&transaction, 2).expect("build document integrity index"); - let digest = document_integrity_digest(&transaction, 0, &ActiveControl) - .expect("derive append receipt"); - transaction - .execute( - "INSERT INTO document_integrity(document_id, chunk_id, digest) VALUES (0, 'chunk', ?1)", - [digest.as_str()], - ) - .expect("seed document receipt"); - - let mut state = PersistedFinalizationStateV1 { - phase: PersistedFinalizationPhaseV1::Digest, - section_ordinal: 1, - section_row_count: 0, - section_last_key: None, - section_accumulator: initial_section_accumulator("document_integrity") - .expect("initial accumulator") - .to_vec(), - completed_sections: Vec::new(), - completed_rows: 0, - content_epoch: 0, - source_state_digest: ManifestDigest::new(format!("sha256:{}", "0".repeat(64))) - .expect("test source-state digest"), - }; - let control = CancelAtCheckpoint { - checkpoint: 8, - observations: AtomicUsize::new(0), - }; - let error = advance_native_section_rows( - &transaction, - FinalizationSectionV1::DocumentIntegrity, - FinalizationSectionV1::DocumentIntegrity.seek_query(false), - params![1i64], - &mut state, - &control, - ) - .expect_err("cancel nested document hashing"); - - assert!(matches!( - error, - CodeLexicalArtifactErrorV1::Interrupted( - tracedecay_code_index::production::CodeIndexInterruptionV1::Cancelled - ) - )); - assert_eq!(state.section_row_count, 0); - assert_eq!(state.completed_rows, 0); - assert_eq!(state.section_last_key, None); - } - #[test] fn bounded_finalization_resume_seeks_each_native_section_index() { let connection = Connection::open_in_memory().expect("open artifact database"); - create_schema(&connection).expect("create artifact schema"); + let _mutation_authority = create_mutable_test_schema(&connection); connection .execute( - "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, next_cursor) VALUES (0, 'page', 'cumulative', 1, 1, 1, 1, 'imports', 'ngrams', X'00')", + "INSERT INTO source_pages(page_ordinal, page_digest, cumulative_digest, chunk_count, payload_bytes, import_count, import_payload_bytes, import_dictionary_digest, ngram_digest, base_sections_receipt, next_cursor) VALUES (0, 'page', 'cumulative', 1, 1, 1, 1, 'imports', 'ngrams', X'00', X'00')", [], ) .expect("seed source page"); diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs index b7c79ac128..4bad03c835 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs @@ -24,10 +24,12 @@ use super::CodeLexicalArtifactErrorV1; // immutable before one authenticated digest pass, defers every serving index // until resumable finalization, and keeps ngram catch-up document-leading. // Revision 7 replaces one row per document n-gram with deterministic -// source-page Roaring bitmap shards. -pub(super) const CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1: u32 = 7; -const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"tracedecay.code-lexical-artifact.v7\0"; -const REQUIRED_ARTIFACT_INDEXES_V7: [(&str, &str, &[&str]); 7] = [ +// source-page Roaring bitmap shards. Revision 8 adds source-page receipts for +// every append-only base section so sealing and reopening need not rescan the +// relational base after the private builder connection has admitted it. +pub(super) const CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1: u32 = 8; +const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"tracedecay.code-lexical-artifact.v8\0"; +const REQUIRED_ARTIFACT_INDEXES_V8: [(&str, &str, &[&str]); 7] = [ ("rows", "rows_by_chunk", &["chunk_id"]), ( "term_postings", @@ -70,6 +72,249 @@ pub(super) const SECTION_NAMES: [&str; 11] = [ "term_stats", "vocabulary", ]; +pub(super) const BASE_SECTION_NAMES: [&str; 7] = [ + "document_integrity", + "import_integrity", + "import_evidence", + "rows", + "term_postings", + "exact_postings", + "ngram_postings", +]; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(super) struct CodeLexicalArtifactPageBaseSectionsReceiptV1 { + page_ordinal: u64, + sections: Vec, +} + +pub(super) struct PageBaseSectionReceiptBuilderV1 { + page_ordinal: u64, + name: &'static str, + row_count: u64, + hasher: Sha256, +} + +impl PageBaseSectionReceiptBuilderV1 { + pub(super) fn new( + page_ordinal: u64, + name: &'static str, + ) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-page-section.v1\0"); + hasher.update(page_ordinal.to_le_bytes()); + hasher.update( + u64::try_from(name.len()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))? + .to_le_bytes(), + ); + hasher.update(name.as_bytes()); + Ok(Self { + page_ordinal, + name, + row_count: 0, + hasher, + }) + } + + pub(super) fn begin_row(&mut self) -> Result<(), CodeLexicalArtifactErrorV1> { + self.hasher.update(b"row\0"); + self.hasher.update(self.row_count.to_le_bytes()); + self.row_count = self.row_count.checked_add(1).ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact page-section row count overflowed".to_owned(), + ) + })?; + Ok(()) + } + + pub(super) fn integer(&mut self, value: i64) { + self.hasher.update([1]); + self.hasher.update(value.to_le_bytes()); + } + + pub(super) fn text(&mut self, value: &str) -> Result<(), CodeLexicalArtifactErrorV1> { + self.hasher.update([3]); + hash_receipt_bytes(&mut self.hasher, value.as_bytes()) + } + + pub(super) fn blob(&mut self, value: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { + self.hasher.update([4]); + hash_receipt_bytes(&mut self.hasher, value) + } + + pub(super) fn finish( + mut self, + ) -> Result { + self.hasher.update(b"end\0"); + self.hasher.update(self.page_ordinal.to_le_bytes()); + self.hasher.update(self.row_count.to_le_bytes()); + let digest = ManifestDigest::from_sha256_bytes(&self.hasher.finalize()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + Ok(CodeLexicalArtifactSectionDigestV1 { + name: self.name.to_owned(), + row_count: self.row_count, + digest, + }) + } +} + +fn hash_receipt_bytes(hasher: &mut Sha256, value: &[u8]) -> Result<(), CodeLexicalArtifactErrorV1> { + hasher.update( + u64::try_from(value.len()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))? + .to_le_bytes(), + ); + hasher.update(value); + Ok(()) +} + +pub(super) fn encode_page_base_sections_receipt( + page_ordinal: u64, + sections: Vec, +) -> Result, CodeLexicalArtifactErrorV1> { + validate_page_base_sections(page_ordinal, §ions)?; + serde_json::to_vec(&CodeLexicalArtifactPageBaseSectionsReceiptV1 { + page_ordinal, + sections, + }) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string())) +} + +pub(super) fn decode_page_base_sections_receipt( + expected_page_ordinal: u64, + bytes: &[u8], +) -> Result { + let receipt: CodeLexicalArtifactPageBaseSectionsReceiptV1 = serde_json::from_slice(bytes) + .map_err(|error| CodeLexicalArtifactErrorV1::Corrupt(error.to_string()))?; + validate_page_base_sections(expected_page_ordinal, &receipt.sections)?; + if receipt.page_ordinal != expected_page_ordinal + || serde_json::to_vec(&receipt) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))? + != bytes + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact page base-section receipt is not canonical".to_owned(), + )); + } + Ok(receipt) +} + +impl CodeLexicalArtifactPageBaseSectionsReceiptV1 { + pub(super) fn sections(&self) -> &[CodeLexicalArtifactSectionDigestV1] { + &self.sections + } +} + +pub(super) fn initial_base_section_receipt_fold() +-> Result<(Vec, Vec>), CodeLexicalArtifactErrorV1> { + let row_counts = vec![0; BASE_SECTION_NAMES.len()]; + let accumulators = BASE_SECTION_NAMES + .into_iter() + .map(|name| { + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-base-receipt-fold.v1\0initial"); + hash_receipt_bytes(&mut hasher, name.as_bytes())?; + Ok(hasher.finalize().to_vec()) + }) + .collect::, CodeLexicalArtifactErrorV1>>()?; + Ok((row_counts, accumulators)) +} + +pub(super) fn absorb_page_base_sections_receipt( + page_ordinal: u64, + bytes: &[u8], + row_counts: &mut [u64], + accumulators: &mut [Vec], +) -> Result<(), CodeLexicalArtifactErrorV1> { + if row_counts.len() != BASE_SECTION_NAMES.len() + || accumulators.len() != BASE_SECTION_NAMES.len() + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt fold has the wrong width".to_owned(), + )); + } + let receipt = decode_page_base_sections_receipt(page_ordinal, bytes)?; + for (ordinal, section) in receipt.sections().iter().enumerate() { + let previous: [u8; 32] = accumulators[ordinal].as_slice().try_into().map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt accumulator has the wrong length".to_owned(), + ) + })?; + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-base-receipt-fold.v1\0page"); + hash_receipt_bytes(&mut hasher, section.name.as_bytes())?; + hasher.update(page_ordinal.to_le_bytes()); + hasher.update(section.row_count.to_le_bytes()); + hash_receipt_bytes(&mut hasher, section.digest.as_str().as_bytes())?; + hasher.update(previous); + accumulators[ordinal] = hasher.finalize().to_vec(); + row_counts[ordinal] = row_counts[ordinal] + .checked_add(section.row_count) + .ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical artifact base-section receipt row count overflowed".to_owned(), + ) + })?; + } + Ok(()) +} + +pub(super) fn finish_base_section_receipt_fold( + row_counts: &[u64], + accumulators: &[Vec], +) -> Result, CodeLexicalArtifactErrorV1> { + if row_counts.len() != BASE_SECTION_NAMES.len() + || accumulators.len() != BASE_SECTION_NAMES.len() + { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt fold has the wrong width".to_owned(), + )); + } + BASE_SECTION_NAMES + .into_iter() + .enumerate() + .map(|(ordinal, name)| { + let accumulator: [u8; 32] = + accumulators[ordinal].as_slice().try_into().map_err(|_| { + CodeLexicalArtifactErrorV1::Corrupt( + "lexical artifact base-section receipt accumulator has the wrong length" + .to_owned(), + ) + })?; + let mut hasher = Sha256::new(); + hasher.update(b"tracedecay.code-lexical-artifact-base-receipt-fold.v1\0final"); + hash_receipt_bytes(&mut hasher, name.as_bytes())?; + hasher.update(row_counts[ordinal].to_le_bytes()); + hasher.update(accumulator); + let digest = ManifestDigest::from_sha256_bytes(&hasher.finalize()) + .map_err(|error| CodeLexicalArtifactErrorV1::Contract(error.to_string()))?; + Ok(CodeLexicalArtifactSectionDigestV1 { + name: name.to_owned(), + row_count: row_counts[ordinal], + digest, + }) + }) + .collect() +} + +fn validate_page_base_sections( + page_ordinal: u64, + sections: &[CodeLexicalArtifactSectionDigestV1], +) -> Result<(), CodeLexicalArtifactErrorV1> { + if sections.len() != BASE_SECTION_NAMES.len() + || sections + .iter() + .zip(BASE_SECTION_NAMES) + .any(|(section, expected)| section.name != expected) + { + return Err(CodeLexicalArtifactErrorV1::Corrupt(format!( + "lexical artifact page {page_ordinal} base-section receipt is malformed" + ))); + } + Ok(()) +} pub(super) fn verify_required_artifact_indexes( connection: &Connection, @@ -82,7 +327,7 @@ pub(super) fn verify_required_artifact_indexes( "artifact index schema is unreadable: {error}" )) })?; - for (table, index, expected_columns) in REQUIRED_ARTIFACT_INDEXES_V7 { + for (table, index, expected_columns) in REQUIRED_ARTIFACT_INDEXES_V8 { let partial: Option = connection .query_row( "SELECT partial FROM pragma_index_list(?1) WHERE name = ?2", @@ -133,6 +378,51 @@ pub(super) fn verify_required_artifact_indexes( pub(super) fn verify_artifact_table_layout( connection: &Connection, ) -> Result<(), CodeLexicalArtifactErrorV1> { + let source_columns = connection + .prepare( + "SELECT name, type, [notnull], pk FROM pragma_table_xinfo('source_pages') WHERE hidden = 0 ORDER BY cid", + ) + .and_then(|mut statement| { + statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + })? + .collect::, _>>() + }) + .map_err(|error| { + CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact source-page columns are unreadable: {error}" + )) + })?; + let expected_source_columns = [ + ("page_ordinal", "INTEGER", 0, 1), + ("page_digest", "TEXT", 1, 0), + ("cumulative_digest", "TEXT", 1, 0), + ("chunk_count", "INTEGER", 1, 0), + ("payload_bytes", "INTEGER", 1, 0), + ("import_count", "INTEGER", 1, 0), + ("import_payload_bytes", "INTEGER", 1, 0), + ("import_dictionary_digest", "TEXT", 1, 0), + ("ngram_digest", "TEXT", 1, 0), + ("base_sections_receipt", "BLOB", 1, 0), + ("next_cursor", "BLOB", 1, 0), + ]; + if !source_columns + .iter() + .map(|(name, column_type, not_null, primary_key)| { + (name.as_str(), column_type.as_str(), *not_null, *primary_key) + }) + .eq(expected_source_columns) + { + return Err(CodeLexicalArtifactErrorV1::Incompatible(format!( + "artifact source-page table has columns {source_columns:?}; revision {CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1} requires append receipts" + ))); + } let without_rowid: Option = connection .query_row( "SELECT wr FROM pragma_table_list WHERE schema = 'main' AND name = 'ngram_postings' AND type = 'table'", diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs index af2daa9b38..268498ac11 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs @@ -10,7 +10,8 @@ use super::super::{ exact_field_for_kind, }; use super::format::{ - ArtifactRowV1, encode_exact_field, encode_field, encode_ngram_bitmap, ngram_page_digest, + ArtifactRowV1, BASE_SECTION_NAMES, PageBaseSectionReceiptBuilderV1, encode_exact_field, + encode_field, encode_ngram_bitmap, encode_page_base_sections_receipt, ngram_page_digest, }; use super::postings::{NGRAM_NORMALIZED, NGRAM_RAW_OVERRIDE, document_ngrams}; use super::{ @@ -33,6 +34,7 @@ pub struct PreparedCodeLexicalArtifactPageV1 { pub(super) documents: Vec, pub(super) ngram_shards: Vec, pub(super) ngram_digest: ManifestDigest, + pub(super) base_sections_receipt: Vec, source_retained_bytes: usize, prepared_retained_bytes: usize, preparation_scratch_bytes: usize, @@ -204,6 +206,13 @@ pub(super) fn prepare_page( ) }), )?; + let base_sections_receipt = prepare_base_sections_receipt( + page.page_ordinal(), + &imports, + &documents, + &ngram_shards, + control, + )?; let aggregation_scratch_bytes = logical_ngram_postings .checked_mul(NGRAM_AGGREGATION_BYTES_PER_LOGICAL_POSTING_V1) .ok_or_else(|| { @@ -233,6 +242,7 @@ pub(super) fn prepare_page( documents, ngram_shards, ngram_digest, + base_sections_receipt, source_retained_bytes: page.retained_owned_bytes(), prepared_retained_bytes: 0, preparation_scratch_bytes, @@ -247,6 +257,88 @@ pub(super) fn prepare_page( Ok(prepared) } +fn prepare_base_sections_receipt( + page_ordinal: u64, + imports: &[PreparedImportV1], + documents: &[PreparedDocumentV1], + ngram_shards: &[PreparedNgramShardV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result, CodeLexicalArtifactErrorV1> { + let mut document_integrity = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[0])?; + let mut import_integrity = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[1])?; + let mut import_evidence = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[2])?; + let mut rows = PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[3])?; + let mut term_postings = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[4])?; + let mut exact_postings = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[5])?; + let mut ngram_postings = + PageBaseSectionReceiptBuilderV1::new(page_ordinal, BASE_SECTION_NAMES[6])?; + + for import in imports { + checkpoint(control)?; + import_integrity.begin_row()?; + import_integrity.blob(&import.canonical)?; + import_integrity.text(import.integrity_digest.as_str())?; + import_evidence.begin_row()?; + import_evidence.blob(&import.canonical)?; + import_evidence.blob(&import.canonical)?; + } + for document in documents { + checkpoint(control)?; + document_integrity.begin_row()?; + document_integrity.integer(document.document_id); + document_integrity.text(&document.chunk_id)?; + document_integrity.text(document.integrity_digest.as_str())?; + + rows.begin_row()?; + rows.integer(document.document_id); + rows.text(&document.chunk_id)?; + rows.blob(&document.row)?; + + for posting in &document.term_postings { + checkpoint(control)?; + term_postings.begin_row()?; + term_postings.text(&posting.field)?; + term_postings.text(&posting.term)?; + term_postings.integer(document.document_id); + term_postings.integer(posting.frequency); + } + for (field, term) in &document.exact_postings { + checkpoint(control)?; + exact_postings.begin_row()?; + exact_postings.text(field)?; + exact_postings.blob(term)?; + exact_postings.integer(document.document_id); + } + } + for shard in ngram_shards { + checkpoint(control)?; + ngram_postings.begin_row()?; + ngram_postings.integer(i64::try_from(page_ordinal).map_err(contract_number)?); + ngram_postings.integer(shard.kind); + ngram_postings.integer(shard.ngram); + ngram_postings.blob(&shard.documents)?; + ngram_postings.integer(i64::try_from(shard.cardinality).map_err(contract_number)?); + } + + encode_page_base_sections_receipt( + page_ordinal, + vec![ + document_integrity.finish()?, + import_integrity.finish()?, + import_evidence.finish()?, + rows.finish()?, + term_postings.finish()?, + exact_postings.finish()?, + ngram_postings.finish()?, + ], + ) +} + fn prepare_document( metadata: &CodeLexicalProjectionMetadataV1, document_id: i64, @@ -491,6 +583,7 @@ fn prepared_retained_bytes( ) }) .and_then(|bytes| bytes.checked_add(page.ngram_digest.as_str().len())) + .and_then(|bytes| bytes.checked_add(page.base_sections_receipt.capacity())) .ok_or_else(prepared_charge_overflow)?; for import in &page.imports { bytes = bytes @@ -550,6 +643,7 @@ fn estimated_sqlite_writes( page.cumulative_digest.as_str(), page.import_dictionary_digest.as_str(), page.ngram_digest.as_str(), + &page.base_sections_receipt, &page.next_cursor, )?; for import in &page.imports { @@ -599,6 +693,7 @@ fn estimated_source_page_receipt_write_bytes( cumulative_digest: &str, import_dictionary_digest: &str, ngram_digest: &str, + base_sections_receipt: &[u8], next_cursor: &[u8], ) -> Result { page_digest @@ -606,6 +701,7 @@ fn estimated_source_page_receipt_write_bytes( .checked_add(cumulative_digest.len()) .and_then(|bytes| bytes.checked_add(import_dictionary_digest.len())) .and_then(|bytes| bytes.checked_add(ngram_digest.len())) + .and_then(|bytes| bytes.checked_add(base_sections_receipt.len())) .and_then(|bytes| bytes.checked_add(next_cursor.len())) .ok_or_else(prepared_write_overflow) } @@ -633,10 +729,10 @@ mod tests { #[test] fn source_page_write_charge_includes_the_ngram_receipt_exactly() { let without_ngram = - estimated_source_page_receipt_write_bytes("p", "cc", "iii", "", b"cursor") + estimated_source_page_receipt_write_bytes("p", "cc", "iii", "", b"", b"cursor") .expect("receipt charge without ngram digest"); let with_ngram = - estimated_source_page_receipt_write_bytes("p", "cc", "iii", "nnnnn", b"cursor") + estimated_source_page_receipt_write_bytes("p", "cc", "iii", "nnnnn", b"", b"cursor") .expect("receipt charge with ngram digest"); assert_eq!(without_ngram, 1 + 2 + 3 + 6); 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 4250b5e346..bdbc6c6cbb 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -663,6 +663,18 @@ fn finish_staged_artifact( } } +fn stored_base_section_receipts(path: &Path) -> Vec> { + let connection = rusqlite::Connection::open(path).expect("open artifact receipt inspection"); + let mut statement = connection + .prepare("SELECT base_sections_receipt FROM source_pages ORDER BY page_ordinal") + .expect("prepare ordered base-section receipt query"); + statement + .query_map([], |row| row.get(0)) + .expect("query ordered base-section receipts") + .collect::, _>>() + .expect("collect ordered base-section receipts") +} + /// Fixture-only source driver retained for legacy regression setup. Production /// finalization receives the source receipt and never owns a source reader. trait TestArtifactSourceStaging { @@ -1206,6 +1218,40 @@ fn disk_artifact_batch_stores_one_ngram_bitmap_shard_per_distinct_key() { ); } +#[test] +fn disk_artifact_base_receipts_are_independent_of_commit_batch_width() { + let (fixture, pages, source_receipt) = real_verified_pages_with_maximum_page_chunks(1); + assert!(pages.len() > 1, "fixture must span multiple source pages"); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let one_page_path = directory.path().join("one-page-receipts.sqlite"); + let batched_path = directory.path().join("batched-receipts.sqlite"); + let control = ArtifactControl { cancelled: false }; + + let mut one_page = + CodeLexicalArtifactBuilderV1::create(&one_page_path, fixture.metadata.clone()) + .expect("create one-page artifact"); + for page in &pages { + one_page + .append_page(page, &control) + .expect("commit one source page"); + } + + let mut batched = CodeLexicalArtifactBuilderV1::create(&batched_path, fixture.metadata) + .expect("create batched artifact"); + batched + .append_pages(&pages, &control) + .expect("commit one multi-page batch"); + + let one_page_receipts = stored_base_section_receipts(&one_page_path); + let batched_receipts = stored_base_section_receipts(&batched_path); + assert_eq!(one_page_receipts.len(), pages.len()); + assert_eq!(one_page_receipts, batched_receipts); + + let one_page_verified = finish_staged_artifact(&mut one_page, &source_receipt, &control); + let batched_verified = finish_staged_artifact(&mut batched, &source_receipt, &control); + assert_eq!(one_page_verified, batched_verified); +} + #[test] fn content_addressed_reader_rejects_atomic_same_size_replacement() { let (fixture, pages, source_receipt) = real_verified_pages(); @@ -1434,7 +1480,8 @@ fn disk_artifact_defers_statistics_and_serving_indexes_until_freeze() { #[test] fn disk_artifact_production_wake_commits_one_restartable_setwise_step() { - let (fixture, pages, source_receipt) = real_verified_pages(); + let fixture = real_lexical_source_fixture_with_files(64); + let (pages, source_receipt) = drain_verified_pages(&fixture, 128); let metadata = fixture.metadata.clone(); let directory = tempfile::tempdir().expect("artifact tempdir"); let artifact_path = directory.path().join("restartable-setwise-steps.sqlite"); @@ -1445,34 +1492,6 @@ fn disk_artifact_production_wake_commits_one_restartable_setwise_step() { builder.append_page(page, &control).expect("append page"); } - // Give the aggregate and index statements a production-shaped VM loop. - // These rows use an authenticated document id, so the freeze accepts the - // base relation while the later digest would still reject the deliberate - // test-only document mismatch. - let connection = rusqlite::Connection::open(&artifact_path).expect("open wide staging file"); - connection - .execute_batch( - "WITH digits(value) AS (VALUES(0),(1),(2),(3),(4),(5),(6),(7),(8),(9)), - numbers(value) AS ( - SELECT ones.value + 10 * tens.value + 100 * hundreds.value + - 1000 * thousands.value + 10000 * ten_thousands.value - FROM digits AS ones - CROSS JOIN digits AS tens - CROSS JOIN digits AS hundreds - CROSS JOIN digits AS thousands - CROSS JOIN digits AS ten_thousands - ), - authority AS ( - SELECT field, document_id FROM term_postings ORDER BY document_id LIMIT 1 - ) - INSERT INTO term_postings(field, term, document_id, frequency) - SELECT authority.field, printf('restart-wide-%05d', numbers.value), - authority.document_id, 1 - FROM authority CROSS JOIN numbers;", - ) - .expect("seed production-width posting relation"); - drop(connection); - assert!(matches!( builder .advance_finalization(&source_receipt, 4_096, &control) @@ -2161,7 +2180,7 @@ fn disk_artifact_seal_is_terminal_and_refuses_page_replay() { } #[test] -fn disk_artifact_first_finalize_rejects_self_attesting_derived_mutation() { +fn disk_artifact_preseal_gate_denies_external_derived_mutation() { let (fixture, pages, source_receipt) = real_verified_pages(); let metadata = fixture.metadata.clone(); let directory = tempfile::tempdir().expect("artifact tempdir"); @@ -2189,73 +2208,58 @@ fn disk_artifact_first_finalize_rejects_self_attesting_derived_mutation() { .expect("import evidence count"); assert!(original_term_postings > 0); assert!(original_imports > 0); - let mut row = original_row.clone(); - row.push(b' '); - connection - .execute( - "UPDATE rows SET row = ?1 WHERE document_id = (SELECT MIN(document_id) FROM rows)", - [row], - ) - .expect("mutate derived row before first seal"); - connection - .execute("DELETE FROM term_postings", []) - .expect("remove derived term postings before first seal"); + let mut mutated_row = original_row.clone(); + mutated_row.push(b' '); + let row_mutation = connection.execute( + "UPDATE rows SET row = ?1 WHERE document_id = (SELECT MIN(document_id) FROM rows)", + [mutated_row], + ); + let posting_mutation = connection.execute("DELETE FROM term_postings", []); + let row_insertion = connection.execute( + "INSERT INTO rows(document_id, chunk_id, row) VALUES (?1, 'external-conflict', X'7b7d')", + [i64::MAX], + ); + assert!( + row_mutation.is_err(), + "schema-time mutation authority must deny external row updates before finalization" + ); + assert!( + posting_mutation.is_err(), + "schema-time mutation authority must deny external posting deletes before finalization" + ); + assert!( + row_insertion.is_err(), + "schema-time mutation authority must deny external row inserts before finalization" + ); let integrity: String = connection .query_row("PRAGMA quick_check(1)", [], |row| row.get(0)) .expect("SQLite integrity check"); assert_eq!(integrity, "ok"); drop(connection); - assert!(matches!( - builder - .advance_finalization(&source_receipt, 1, &control) - .expect("persist freeze before authenticated scan"), - CodeLexicalArtifactFinalizationStepV1::Pending { .. } - )); - let corrupted = loop { - match builder.advance_finalization(&source_receipt, 4_096, &control) { - Ok(CodeLexicalArtifactFinalizationStepV1::Pending { .. }) => {} - result => break result, - } - }; - assert!( - matches!(corrupted, Err(CodeLexicalArtifactErrorV1::Corrupt(_))), - "mutated derived content must fail authenticated finalization: {corrupted:?}" - ); - - // A corrupted staging file must fail closed. Recovery explicitly restages - // trusted pages into a fresh artifact; bounded finalization never replays - // the source just to overwrite mutable derived rows. - let recovered_path = directory.path().join("recovered-preseal-artifact.sqlite"); - let mut recovered = CodeLexicalArtifactBuilderV1::create(&recovered_path, fixture.metadata) - .expect("create fresh recovery artifact"); - for page in &pages { - recovered - .append_page(page, &control) - .expect("restage trusted page"); - } - let verified = finish_staged_artifact(&mut recovered, &source_receipt, &control); + let verified = finish_staged_artifact(&mut builder, &source_receipt, &control); CodeLexicalArtifactReaderV1::open_with_control( - &recovered_path, + &artifact_path, &verified, CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, &control, ) - .expect("freshly staged artifact verifies"); - let connection = rusqlite::Connection::open(&recovered_path).expect("inspect recovery"); + .expect("denied mutation preserves a readable finalized artifact"); + let connection = + rusqlite::Connection::open(&artifact_path).expect("inspect finalized artifact"); let rebuilt_row: Vec = connection .query_row( "SELECT row FROM rows ORDER BY document_id LIMIT 1", [], |row| row.get(0), ) - .expect("recovered artifact row"); + .expect("finalized artifact row"); let rebuilt_term_postings: i64 = connection .query_row("SELECT COUNT(*) FROM term_postings", [], |row| row.get(0)) - .expect("recovered term posting count"); + .expect("finalized term posting count"); let rebuilt_imports: i64 = connection .query_row("SELECT COUNT(*) FROM import_evidence", [], |row| row.get(0)) - .expect("recovered import evidence count"); + .expect("finalized import evidence count"); assert_eq!(rebuilt_row, original_row); assert_eq!(rebuilt_term_postings, original_term_postings); assert_eq!(rebuilt_imports, original_imports); @@ -2523,60 +2527,6 @@ fn staged_row_cardinality(artifact_path: &Path) -> (u64, u64) { ) } -#[test] -fn disk_artifact_multi_page_append_rolls_back_the_whole_fresh_suffix() { - let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); - assert!(pages.len() >= 2, "fixture must emit a multi-page batch"); - let directory = tempfile::tempdir().expect("artifact tempdir"); - let artifact_path = directory.path().join("atomic-page-batch.sqlite"); - let control = ArtifactControl { cancelled: false }; - let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) - .expect("create artifact"); - - // Force the second page's document insert to fail after the first page - // has already performed relational writes in the same transaction. - let second_document = i64::try_from(pages[0].chunk_count()).expect("first page document count"); - let connection = rusqlite::Connection::open(&artifact_path).expect("open failure fixture"); - connection - .execute( - "INSERT INTO rows(document_id, chunk_id, row) VALUES (?1, 'forced-conflict', X'7b7d')", - [second_document], - ) - .expect("install second-page conflict"); - drop(connection); - - assert!(matches!( - builder.append_pages(&pages[..2], &control), - Err(CodeLexicalArtifactErrorV1::Contract(_)) - )); - assert_eq!( - builder - .progress() - .expect("progress after rollback") - .next_page_ordinal, - 0, - "a failed later page must roll back every earlier page in the batch" - ); - let connection = rusqlite::Connection::open(&artifact_path).expect("inspect rolled back batch"); - let receipts: i64 = connection - .query_row("SELECT COUNT(*) FROM source_pages", [], |row| row.get(0)) - .expect("source receipt count"); - assert_eq!(receipts, 0, "no page receipt may escape the failed batch"); - connection - .execute("DELETE FROM rows WHERE document_id = ?1", [second_document]) - .expect("remove forced conflict"); - drop(connection); - - let progress = builder - .append_pages(&pages[..2], &control) - .expect("retry exact ordered batch"); - assert_eq!(progress.next_page_ordinal, 2); - assert_eq!( - progress.completed_chunks, - pages[0].chunk_count() + pages[1].chunk_count() - ); -} - #[test] fn disk_artifact_receipt_failure_rolls_back_prior_page_rows_and_receipts() { let (fixture, pages, _) = real_verified_pages_with_maximum_page_chunks(1); From b981b2f80c9775823b64104ca7a5a8ac29298254 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 18:28:29 +0000 Subject: [PATCH 58/69] perf(query): widen lexical build reservation --- .../retrieval/lexical/projection/artifact.rs | 2 +- .../candidate_producers.rs | 159 ++++++++++++++++-- 2 files changed, 143 insertions(+), 18 deletions(-) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs index bffeb26f69..3e087484c6 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact.rs @@ -41,7 +41,7 @@ pub use reader::{CodeExactLexicalArtifactReaderV1, CodeLexicalArtifactReaderV1}; /// is a target the engine may transiently exceed, per-statement and /// allocator metadata overhead are unaccounted, and `temp_store = FILE` /// keeps temporary b-trees on disk rather than bounding them in memory. -pub const CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1: usize = 768 * 1024 * 1024; +pub const CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1: usize = 1536 * 1024 * 1024; /// Maximum reader cache budget: the stored metadata copy plus the SQLite /// page-cache grant, which stays inside the kernel SQLite window ([2, 64] /// MiB page cache, mmap disabled). The reader's retained claim is the 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 bdbc6c6cbb..1a7a3c03ba 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -49,8 +49,10 @@ use tracedecay_query::retrieval::exact::{ }; use tracedecay_query::retrieval::lexical::{ CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, - CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeLexicalArtifactBuilderV1, - CodeLexicalArtifactErrorV1, CodeLexicalArtifactFinalizationStepV1, CodeLexicalArtifactReaderV1, + CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1, + CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeLexicalArtifactBatchLimitV1, + CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, + CodeLexicalArtifactFinalizationStepV1, CodeLexicalArtifactReaderV1, CodeLexicalProjectionAdapterV1, CodeLexicalProjectionBuildStepV1, CodeLexicalProjectionBuildV1, CodeLexicalProjectionMetadataV1, LexicalFieldFilterV1, LexicalFieldV1, LexicalLane, LexicalLaneRequest, LexicalLaneRetriever, MAX_FUZZY_TERM_EXPANSIONS_V1, @@ -1722,34 +1724,157 @@ fn disk_artifact_admission_keeps_real_pages_wide_until_the_actual_limit() { } #[test] -fn disk_artifact_large_build_authority_commits_thirty_two_real_pages() { - let fixture = real_lexical_source_fixture_with_files(32); - let (pages, _) = drain_verified_pages(&fixture, 1); +fn disk_artifact_widened_reservation_commits_high_ngram_window_atomically() { + const PRIOR_BUILD_BUDGET_BYTES: usize = 768 * 1024 * 1024; + const WIDENED_BUILD_BUDGET_BYTES: usize = 1536 * 1024 * 1024; + const SOURCE_WINDOW_BYTES: usize = 64 * 1024 * 1024; + const MAXIMUM_PREPARED_BATCH_ROWS: usize = 2_000_000; + const MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES: usize = 256 * 1024 * 1024; + + let sources = (0..32) + .map(|file_ordinal| { + let mut source = String::with_capacity(128 * 1024); + for symbol_ordinal in 0..128 { + let mut state = u64::try_from(file_ordinal * 128 + symbol_ordinal + 1) + .expect("fixture seed"); + let token = (0..240) + .map(|_| { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + let alphabet_ordinal = + usize::try_from((state >> 32) % 26).expect("alphabet ordinal"); + char::from(b'a' + u8::try_from(alphabet_ordinal).expect("ASCII letter")) + }) + .collect::(); + source.push_str(&format!( + "export function symbol_{file_ordinal:02}_{symbol_ordinal:03}(value: string) {{ return value + '{token}'; }}\n" + )); + } + ( + format!("file.artifact.high-ngram.{file_ordinal:02}"), + format!("src/high-ngram-{file_ordinal:02}.ts"), + source.into_bytes(), + ) + }) + .collect(); + let fixture = real_lexical_source_fixture_from_sources(sources); + let (pages, _) = drain_verified_pages(&fixture, 128); assert!( pages.len() >= 32, - "the parser-backed corpus must expose a full 32-page source window" + "the parser-backed high-ngram corpus must expose a full 32-page source window" ); + let pages = &pages[..32]; let directory = tempfile::tempdir().expect("artifact tempdir"); let artifact_path = directory.path().join("thirty-two-page-batch.sqlite"); - let mut builder = CodeLexicalArtifactBuilderV1::create_with_memory_budget( - &artifact_path, - fixture.metadata, - 768 * 1024 * 1024, + + let prior_path = directory.path().join("prior-reservation.sqlite"); + let mut prior = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &prior_path, + fixture.metadata.clone(), + PRIOR_BUILD_BUDGET_BYTES, ) - .expect("create builder with the production large-batch authority"); + .expect("create builder with the prior reservation"); + let batch_charge = prior + .fixed_ledger_charge_bytes() + .checked_add( + prior + .page_batch_ledger_charge_bytes(pages) + .expect("measure high-ngram window"), + ) + .expect("high-ngram window ledger charge"); + assert!( + batch_charge > PRIOR_BUILD_BUDGET_BYTES, + "the production-shaped window must reproduce the measured 768 MiB memory limit: {batch_charge}" + ); + assert!( + batch_charge <= WIDENED_BUILD_BUDGET_BYTES, + "the same bounded window must fit the widened reservation: {batch_charge}" + ); + assert!( + prior + .largest_admissible_page_prefix(pages) + .expect("select prior reservation prefix") + < pages.len(), + "the prior reservation must stop before the complete high-ngram window" + ); + assert!(matches!( + prior.append_pages(pages, &ArtifactControl { cancelled: false }), + Err(CodeLexicalArtifactErrorV1::BatchTooLarge { + limit: CodeLexicalArtifactBatchLimitV1::Memory, + required, + maximum: PRIOR_BUILD_BUDGET_BYTES, + }) if required == batch_charge + )); + assert_eq!( + prior + .progress() + .expect("progress after typed reservation denial") + .next_page_ordinal, + 0, + "the memory denial must precede the atomic staging transaction" + ); + drop(prior); + + assert_eq!( + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, WIDENED_BUILD_BUDGET_BYTES, + "the canonical reservation must cover the measured production window" + ); + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) + .expect("create builder with the canonical widened reservation"); + assert_eq!( + builder + .largest_admissible_page_prefix(pages) + .expect("select widened reservation prefix"), + pages.len(), + "the widened memory authority must admit the complete window" + ); + let source_retained_bytes = pages + .iter() + .map(VerifiedSealedLexicalPageV1::retained_owned_bytes) + .sum::(); + assert!( + source_retained_bytes <= SOURCE_WINDOW_BYTES, + "the fixture must remain inside the 64 MiB source window: {source_retained_bytes}" + ); + assert!( + pages.iter().all(|page| { + page.retained_owned_bytes() <= CODE_LEXICAL_ARTIFACT_MAXIMUM_PAGE_RETAINED_BYTES_V1 + }), + "every source page must remain inside its unchanged retained-byte bound" + ); let control = ArtifactControl { cancelled: false }; let prepared = builder - .prepare_admissible_page_prefix(&pages[..32], &control) + .prepare_pages(pages, &control) .expect("prepare one full production source window"); - assert_eq!( - prepared.accepted_prefix().get(), - 32, - "the tracked memory, row, and write authorities must admit the complete real prefix" + let estimated_rows = prepared + .iter() + .map(|page| page.estimated_write_rows()) + .sum::(); + let estimated_write_bytes = prepared + .iter() + .map(|page| page.estimated_write_bytes()) + .sum::(); + assert!( + estimated_rows <= MAXIMUM_PREPARED_BATCH_ROWS, + "the high-ngram window must remain inside the unchanged row bound: {estimated_rows}" + ); + assert!( + estimated_write_bytes <= MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES, + "the high-ngram window must remain inside the unchanged write bound: {estimated_write_bytes}" ); let progress = builder - .append_prepared_pages(prepared.prepared_pages(), &control) + .append_prepared_pages(&prepared, &control) .expect("commit the complete real prefix atomically"); assert_eq!(progress.next_page_ordinal, 32); + assert_eq!( + staged_row_cardinality(&artifact_path).0, + pages + .iter() + .map(VerifiedSealedLexicalPageV1::chunk_count) + .sum::(), + "one transaction must make every page row visible together" + ); } #[test] From 7d36c38829b03208f8835ce6bd01cccdf84bae49 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 18:33:01 +0000 Subject: [PATCH 59/69] test(query): cover net lexical build reservation --- .../search_quality_suite/candidate_producers.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 1a7a3c03ba..20b79aa995 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -1783,13 +1783,17 @@ fn disk_artifact_widened_reservation_commits_high_ngram_window_atomically() { .expect("measure high-ngram window"), ) .expect("high-ngram window ledger charge"); + let staging_window_bytes = fixture.open_source(128).staging_window_bytes(); + let production_builder_budget = WIDENED_BUILD_BUDGET_BYTES + .checked_sub(staging_window_bytes) + .expect("production builder budget after source reservation"); assert!( batch_charge > PRIOR_BUILD_BUDGET_BYTES, "the production-shaped window must reproduce the measured 768 MiB memory limit: {batch_charge}" ); assert!( - batch_charge <= WIDENED_BUILD_BUDGET_BYTES, - "the same bounded window must fit the widened reservation: {batch_charge}" + batch_charge <= production_builder_budget, + "the same bounded window must fit after the production source reservation: batch={batch_charge}, staging={staging_window_bytes}, builder={production_builder_budget}" ); assert!( prior @@ -1820,8 +1824,12 @@ fn disk_artifact_widened_reservation_commits_high_ngram_window_atomically() { CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, WIDENED_BUILD_BUDGET_BYTES, "the canonical reservation must cover the measured production window" ); - let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) - .expect("create builder with the canonical widened reservation"); + let mut builder = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &artifact_path, + fixture.metadata, + production_builder_budget, + ) + .expect("create builder after the production source reservation"); assert_eq!( builder .largest_admissible_page_prefix(pages) From cdd591fc943826064f10fed6f9ddc48d06d5ed8e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 19:00:46 +0000 Subject: [PATCH 60/69] perf(query): order lexical term batch inserts --- .../lexical/projection/artifact/builder.rs | 191 ++++++++++++++++-- .../candidate_producers.rs | 173 ++++++++++++++++ 2 files changed, 346 insertions(+), 18 deletions(-) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index 16cf65c5f2..d0fe2caafd 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -37,7 +37,9 @@ use super::format::{ verify_required_artifact_indexes, }; use super::postings::document_ngram_scratch; -use super::prepared::{PreparedCodeLexicalArtifactPageV1, prepare_page as prepare_page_values}; +use super::prepared::{ + PreparedCodeLexicalArtifactPageV1, PreparedTermPostingV1, prepare_page as prepare_page_values, +}; use super::{ ARTIFACT_SQLITE_CACHE_BYTES, CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, CODE_LEXICAL_ARTIFACT_MAXIMUM_ESTIMATED_BATCH_WRITE_BYTES_V1, @@ -54,6 +56,11 @@ const PROGRESS_TAIL_QUERY: &str = "SELECT page_ordinal, import_dictionary_digest FROM source_pages ORDER BY page_ordinal DESC LIMIT 1"; const FINALIZATION_PROGRESS_INTERVAL_OPS: i32 = 4_096; const FINALIZATION_CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(1); +// A plan entry owns one document id and one posting reference. Three words +// cover its portable 32-bit layout and conservatively exceed its 64-bit +// layout; general allocator metadata remains outside the ledger contract. +const TERM_INSERT_PLAN_BYTES_PER_REF: usize = 3 * std::mem::size_of::(); +const TERM_INSERT_CONTROL_INTERVAL: usize = 4_096; // This gate serializes mutation within the private-profile/stable-handle // authority. It denies ordinary second-connection DML, but is not a // cryptographic defense against malicious same-UID code that deliberately @@ -61,6 +68,13 @@ const FINALIZATION_CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(1); const BUILDER_MUTATION_GATE_FUNCTION: &str = "tracedecay_lexical_builder_append_authorized"; const BUILDER_MUTATION_IDLE: u8 = 0; const BUILDER_MUTATION_APPEND: u8 = 1; + +#[derive(Clone, Copy)] +struct PreparedTermInsertRefV1<'a> { + document_id: i64, + posting: &'a PreparedTermPostingV1, +} + const BUILDER_GATE_TRIGGER_LAYOUT: [(&str, &str, &str); 14] = [ ("builder_gate_source_pages_insert", "source_pages", "INSERT"), ( @@ -999,7 +1013,11 @@ impl CodeLexicalArtifactBuilderV1 { } let (replayed_prefix, mut prepared) = self.prepare_pages_inner(&pages[..memory_prefix], control)?; - let (fresh_prefix, exact_limit) = largest_exact_prepared_prefix(&prepared)?; + let (fresh_prefix, exact_limit) = largest_exact_prepared_prefix( + &prepared, + self.fixed_ledger_charge_bytes, + self.memory_budget_bytes, + )?; if fresh_prefix == 0 && !prepared.is_empty() { let exceeded = exact_limit.ok_or_else(|| { CodeLexicalArtifactErrorV1::Contract( @@ -1166,6 +1184,15 @@ impl CodeLexicalArtifactBuilderV1 { self.memory_budget_bytes, pages, )?; + let term_insert_plan = hotpath::measure_block!( + "query.artifact.batch.term_order", + prepare_term_insert_plan( + self.fixed_ledger_charge_bytes, + self.memory_budget_bytes, + pages, + control, + ) + )?; hotpath::measure_block!("query.artifact.batch.sqlite", { let _mutation_authority = BuilderMutationGuardV1::enter(&self.mutation_gate)?; let transaction = self.connection.transaction().map_err(sqlite_error)?; @@ -1184,7 +1211,7 @@ impl CodeLexicalArtifactBuilderV1 { record_batch_row_metrics(pages); hotpath::measure_block!( "query.artifact.batch.postings", - append_prepared_postings(&transaction, pages, control) + append_prepared_postings(&transaction, pages, &term_insert_plan, control) )?; record_batch_posting_metrics(pages); hotpath::measure_block!("query.artifact.batch.receipts", { @@ -1763,6 +1790,8 @@ impl CanonicalBatchLimitLedgerV1 { fn largest_exact_prepared_prefix( pages: &[PreparedCodeLexicalArtifactPageV1], + fixed_ledger_charge_bytes: usize, + memory_budget_bytes: usize, ) -> Result<(usize, Option), CodeLexicalArtifactErrorV1> { let mut ledger = CanonicalBatchLimitLedgerV1::default(); for (index, page) in pages.iter().enumerate() { @@ -1771,6 +1800,20 @@ fn largest_exact_prepared_prefix( { return Ok((index, Some(exceeded))); } + let required = prepared_batch_memory_with_term_plan_required_bytes( + fixed_ledger_charge_bytes, + &pages[..=index], + )?; + if required > memory_budget_bytes { + return Ok(( + index, + Some(BatchLimitExceededV1 { + limit: CodeLexicalArtifactBatchLimitV1::Memory, + required, + maximum: memory_budget_bytes, + }), + )); + } } Ok((pages.len(), None)) } @@ -1810,11 +1853,10 @@ fn admit_page_batch_within_memory_budget( Ok(()) } -fn admit_prepared_page_batch( +fn prepared_batch_memory_required_bytes( fixed_ledger_charge_bytes: usize, - memory_budget_bytes: usize, pages: &[PreparedCodeLexicalArtifactPageV1], -) -> Result<(), CodeLexicalArtifactErrorV1> { +) -> Result { let source_retained = pages.iter().try_fold(0usize, |total, page| { total .checked_add(page.source_retained_bytes()) @@ -1848,7 +1890,7 @@ fn admit_prepared_page_batch( "prepared lexical batch active-worker scratch charge overflowed".to_owned(), ) })?; - let required = fixed_ledger_charge_bytes + fixed_ledger_charge_bytes .checked_add(source_retained) .and_then(|bytes| bytes.checked_add(prepared_retained)) .and_then(|bytes| bytes.checked_add(active_scratch)) @@ -1856,7 +1898,43 @@ fn admit_prepared_page_batch( CodeLexicalArtifactErrorV1::Contract( "prepared lexical batch total ledger charge overflowed".to_owned(), ) - })?; + }) +} + +fn prepared_term_row_count( + pages: &[PreparedCodeLexicalArtifactPageV1], +) -> Result { + pages + .iter() + .flat_map(|page| &page.documents) + .try_fold(0usize, |rows, document| { + rows.checked_add(document.term_postings.len()) + .ok_or_else(batch_ledger_overflow) + }) +} + +fn term_insert_plan_ledger_bytes(term_rows: usize) -> Result { + term_rows + .checked_mul(TERM_INSERT_PLAN_BYTES_PER_REF) + .ok_or_else(batch_ledger_overflow) +} + +fn prepared_batch_memory_with_term_plan_required_bytes( + fixed_ledger_charge_bytes: usize, + pages: &[PreparedCodeLexicalArtifactPageV1], +) -> Result { + let base = prepared_batch_memory_required_bytes(fixed_ledger_charge_bytes, pages)?; + let plan = term_insert_plan_ledger_bytes(prepared_term_row_count(pages)?)?; + base.checked_add(plan).ok_or_else(batch_ledger_overflow) +} + +fn admit_prepared_page_batch( + fixed_ledger_charge_bytes: usize, + memory_budget_bytes: usize, + pages: &[PreparedCodeLexicalArtifactPageV1], +) -> Result<(), CodeLexicalArtifactErrorV1> { + let required = + prepared_batch_memory_with_term_plan_required_bytes(fixed_ledger_charge_bytes, pages)?; if required > memory_budget_bytes { return Err(batch_limit( CodeLexicalArtifactBatchLimitV1::Memory, @@ -1891,6 +1969,79 @@ fn admit_prepared_page_batch( Ok(()) } +fn prepare_term_insert_plan<'a>( + fixed_ledger_charge_bytes: usize, + memory_budget_bytes: usize, + pages: &'a [PreparedCodeLexicalArtifactPageV1], + control: &dyn CodeIndexExecutionControlV1, +) -> Result>, CodeLexicalArtifactErrorV1> { + checkpoint(control)?; + let mut term_rows = 0usize; + for page in pages { + checkpoint(control)?; + for document in &page.documents { + checkpoint(control)?; + term_rows = term_rows + .checked_add(document.term_postings.len()) + .ok_or_else(batch_ledger_overflow)?; + if term_rows > CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1 { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::PreparedRows, + term_rows, + CODE_LEXICAL_ARTIFACT_MAXIMUM_PREPARED_BATCH_ROWS_V1, + )); + } + } + } + let plan_bytes = term_insert_plan_ledger_bytes(term_rows)?; + let required = prepared_batch_memory_required_bytes(fixed_ledger_charge_bytes, pages)? + .checked_add(plan_bytes) + .ok_or_else(batch_ledger_overflow)?; + if required > memory_budget_bytes { + return Err(batch_limit( + CodeLexicalArtifactBatchLimitV1::Memory, + required, + memory_budget_bytes, + )); + } + + let mut plan = Vec::new(); + plan.try_reserve_exact(term_rows).map_err(|error| { + CodeLexicalArtifactErrorV1::Io(format!( + "bounded lexical term insert plan allocation failed: {error}" + )) + })?; + for page in pages { + checkpoint(control)?; + for document in &page.documents { + checkpoint(control)?; + plan.extend( + document + .term_postings + .iter() + .map(|posting| PreparedTermInsertRefV1 { + document_id: document.document_id, + posting, + }), + ); + } + } + plan.sort_unstable_by(|left, right| { + ( + left.posting.field.as_str(), + left.posting.term.as_str(), + left.document_id, + ) + .cmp(&( + right.posting.field.as_str(), + right.posting.term.as_str(), + right.document_id, + )) + }); + checkpoint(control)?; + Ok(plan) +} + fn sum_prepared_metric( pages: &[PreparedCodeLexicalArtifactPageV1], metric: impl Fn(&PreparedCodeLexicalArtifactPageV1) -> usize, @@ -2397,6 +2548,7 @@ fn append_prepared_imports( fn append_prepared_postings( transaction: &Transaction<'_>, pages: &[PreparedCodeLexicalArtifactPageV1], + term_insert_plan: &[PreparedTermInsertRefV1<'_>], control: &dyn CodeIndexExecutionControlV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { let mut term_statement = transaction @@ -2414,19 +2566,22 @@ fn append_prepared_postings( "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (?1, ?2, ?3, ?4, ?5)", ) .map_err(sqlite_error)?; + for (ordinal, entry) in term_insert_plan.iter().enumerate() { + if ordinal.is_multiple_of(TERM_INSERT_CONTROL_INTERVAL) { + checkpoint(control)?; + } + term_statement + .execute(params![ + entry.posting.field.as_str(), + entry.posting.term.as_str(), + entry.document_id, + entry.posting.frequency + ]) + .map_err(sqlite_error)?; + } for page in pages { for document in &page.documents { checkpoint(control)?; - for posting in &document.term_postings { - term_statement - .execute(params![ - posting.field.as_str(), - posting.term.as_str(), - document.document_id, - posting.frequency - ]) - .map_err(sqlite_error)?; - } for (field, term) in &document.exact_postings { exact_statement .execute(params![ 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 20b79aa995..e1bf94398a 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -1723,6 +1723,179 @@ fn disk_artifact_admission_keeps_real_pages_wide_until_the_actual_limit() { assert_eq!(progress.next_page_ordinal, 2); } +#[test] +fn disk_artifact_term_insert_execution_is_monotone_by_primary_key() { + let (fixture, pages, _) = real_verified_pages(); + let directory = tempfile::tempdir().expect("artifact tempdir"); + let artifact_path = directory.path().join("term-insert-order.sqlite"); + let mut builder = CodeLexicalArtifactBuilderV1::create(&artifact_path, fixture.metadata) + .expect("create artifact"); + let trace = rusqlite::Connection::open(&artifact_path).expect("open term insert observer"); + trace + .execute_batch( + "CREATE TABLE term_insert_trace ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + field TEXT NOT NULL, + term TEXT NOT NULL, + document_id INTEGER NOT NULL + ); + CREATE TRIGGER trace_term_insert AFTER INSERT ON term_postings BEGIN + INSERT INTO term_insert_trace(field, term, document_id) + VALUES (NEW.field, NEW.term, NEW.document_id); + END;", + ) + .expect("install term insert observer"); + drop(trace); + + builder + .append_pages(&pages, &ArtifactControl { cancelled: false }) + .expect("append observed term postings"); + let trace = rusqlite::Connection::open(&artifact_path).expect("read term insert observer"); + let keys = trace + .prepare("SELECT field, term, document_id FROM term_insert_trace ORDER BY sequence") + .expect("prepare term insert trace") + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .expect("query term insert trace") + .collect::, _>>() + .expect("read term insert trace"); + assert!(keys.len() > 1, "fixture must emit multiple term postings"); + let resets = keys.windows(2).filter(|pair| pair[1] < pair[0]).count(); + assert_eq!( + resets, 0, + "term INSERT execution must follow the WITHOUT ROWID primary key" + ); +} + +#[test] +fn disk_artifact_term_insert_plan_obeys_exact_memory_boundary_before_mutation() { + const TERM_INSERT_PLAN_BYTES_PER_REF: usize = 3 * std::mem::size_of::(); + + let (fixture, pages, _) = real_verified_pages(); + let pages = &pages[..1]; + let metadata = fixture.metadata; + let directory = tempfile::tempdir().expect("artifact tempdir"); + let probe_path = directory.path().join("term-plan-probe.sqlite"); + let mut probe = CodeLexicalArtifactBuilderV1::create(&probe_path, metadata.clone()) + .expect("create term plan probe"); + let control = ArtifactControl { cancelled: false }; + let prepared = probe + .prepare_pages(pages, &control) + .expect("prepare term plan fixture"); + let prepared_ledger = prepared[0] + .ledger_charge_bytes() + .expect("prepared page ledger charge"); + let fixed_ledger = probe.fixed_ledger_charge_bytes(); + probe + .append_prepared_pages(&prepared, &control) + .expect("append term plan probe"); + let term_rows = rusqlite::Connection::open(&probe_path) + .expect("open term plan probe") + .query_row("SELECT COUNT(*) FROM term_postings", [], |row| { + row.get::<_, i64>(0) + }) + .expect("count prepared term rows"); + let term_rows = usize::try_from(term_rows).expect("term row count"); + assert!(term_rows > 0, "fixture must emit term postings"); + let plan_ledger = term_rows + .checked_mul(TERM_INSERT_PLAN_BYTES_PER_REF) + .expect("term plan ledger charge"); + let exact_budget = fixed_ledger + .checked_add(prepared_ledger) + .and_then(|bytes| bytes.checked_add(plan_ledger)) + .expect("exact term plan budget"); + drop(probe); + + let refused_path = directory.path().join("term-plan-refused.sqlite"); + let mut refused = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &refused_path, + metadata.clone(), + exact_budget - 1, + ) + .expect("create one-byte-under term plan builder"); + assert_eq!(refused.fixed_ledger_charge_bytes(), fixed_ledger); + assert!(matches!( + refused.append_prepared_pages(&prepared, &control), + Err(CodeLexicalArtifactErrorV1::BatchTooLarge { + limit: CodeLexicalArtifactBatchLimitV1::Memory, + required, + maximum, + }) if required == exact_budget && maximum == exact_budget - 1 + )); + assert_eq!( + refused + .progress() + .expect("progress after term plan refusal") + .next_page_ordinal, + 0 + ); + assert_eq!(staged_row_cardinality(&refused_path), (0, 0)); + let refused_term_rows: i64 = rusqlite::Connection::open(&refused_path) + .expect("open refused term plan artifact") + .query_row("SELECT COUNT(*) FROM term_postings", [], |row| row.get(0)) + .expect("count refused term rows"); + assert_eq!(refused_term_rows, 0); + drop(refused); + + let interrupted_path = directory.path().join("term-plan-interrupted.sqlite"); + let mut interrupted = CodeLexicalArtifactBuilderV1::create(&interrupted_path, metadata.clone()) + .expect("create interrupted term plan builder"); + let documents = usize::try_from(prepared[0].chunk_count()).expect("prepared document count"); + // append entry + plan entry + page/document count pass + page/document + // collection pass + the post-sort checkpoint. + let post_sort_observation = documents + .checked_mul(2) + .and_then(|observations| observations.checked_add(5)) + .expect("post-sort observation"); + let cancellation = CancelAtObservation::new(post_sort_observation); + assert!(matches!( + interrupted.append_prepared_pages(&prepared, &cancellation), + Err(CodeLexicalArtifactErrorV1::Interrupted(_)) + )); + assert_eq!( + interrupted + .progress() + .expect("progress after term plan interruption") + .next_page_ordinal, + 0, + "post-sort cancellation must precede transaction entry" + ); + assert_eq!(staged_row_cardinality(&interrupted_path), (0, 0)); + drop(interrupted); + let mut resumed = CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &interrupted_path, + metadata.clone(), + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("resume after term plan interruption"); + assert_eq!( + resumed + .append_prepared_pages(&prepared, &control) + .expect("resume exact prepared batch") + .next_page_ordinal, + 1 + ); + drop(resumed); + + let exact_path = directory.path().join("term-plan-exact.sqlite"); + let mut exact = CodeLexicalArtifactBuilderV1::create_with_memory_budget( + &exact_path, + metadata, + exact_budget, + ) + .expect("create exact term plan builder"); + let progress = exact + .append_prepared_pages(&prepared, &control) + .expect("accept exact term plan boundary"); + assert_eq!(progress.next_page_ordinal, 1); +} + #[test] fn disk_artifact_widened_reservation_commits_high_ngram_window_atomically() { const PRIOR_BUILD_BUDGET_BYTES: usize = 768 * 1024 * 1024; From e6a5f5f671a249f3628b04ffdc3d26a1d2fc98ca Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 19:09:46 +0000 Subject: [PATCH 61/69] fix(semantic): build activation candidate in daemon --- src/bin/tracedecay-search-eval-direct.rs | 45 +++- .../generation_retention_test.rs | 16 +- .../semantic_activation_journey_test.rs | 150 +------------ .../semantic_availability_journey_test.rs | 6 +- src/daemon/semantic_evaluation.rs | 197 +++++++++++++++++- src/daemon/service/invocation/dispatch.rs | 4 +- .../service/invocation/semantic_evaluation.rs | 102 +++++++-- .../tests/project_admission_tests.rs | 33 +-- src/daemon_client.rs | 8 +- src/daemon_contract.rs | 41 +++- .../advanced_workflow_journey/task_session.rs | 189 +---------------- 11 files changed, 374 insertions(+), 417 deletions(-) diff --git a/src/bin/tracedecay-search-eval-direct.rs b/src/bin/tracedecay-search-eval-direct.rs index 7922883dd7..0cb4f260d9 100644 --- a/src/bin/tracedecay-search-eval-direct.rs +++ b/src/bin/tracedecay-search-eval-direct.rs @@ -67,7 +67,7 @@ enum Command { #[arg(long, default_value = ".")] project_root: PathBuf, #[arg(long)] - candidate: PathBuf, + profile: String, }, /// Run the native evaluator in the owning daemon and write only its /// independently validated qualification evidence. @@ -143,8 +143,8 @@ fn main() -> ExitCode { }, Command::EvaluateAndPublish { project_root, - candidate, - } => evaluate_and_publish(project_root, candidate), + profile, + } => evaluate_and_publish(project_root, profile), Command::QualifyNative { project_root, candidate, @@ -163,11 +163,7 @@ fn validate_requested_workload( ) } -fn evaluate_and_publish(project_root: PathBuf, candidate_path: PathBuf) -> ExitCode { - let candidate = match read_semantic_candidate(&candidate_path) { - Ok(candidate) => candidate, - Err(error) => return invalid("evaluate_and_publish", error), - }; +fn evaluate_and_publish(project_root: PathBuf, evaluated_profile_id: String) -> ExitCode { let runtime = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -187,7 +183,7 @@ fn evaluate_and_publish(project_root: PathBuf, candidate_path: PathBuf) -> ExitC }; match client .evaluate_and_publish_semantic_profile_until( - candidate, + &evaluated_profile_id, SEMANTIC_EVALUATION_ISOLATED_DISPATCH_DEADLINE_MICROS, ) .await @@ -426,6 +422,37 @@ mod tests { )); } + #[test] + fn evaluate_and_publish_accepts_only_a_daemon_owned_profile_selection() { + let cli = Cli::try_parse_from([ + "tracedecay-search-eval", + "evaluate-and-publish", + "--project-root", + "project", + "--profile", + "hybrid-conservative", + ]) + .expect("evaluate-and-publish profile arguments parse"); + + assert!(matches!( + cli.command, + Command::EvaluateAndPublish { + project_root, + profile, + } if project_root == *"project" && profile == "hybrid-conservative" + )); + assert!( + Cli::try_parse_from([ + "tracedecay-search-eval", + "evaluate-and-publish", + "--candidate", + "caller-authored.json", + ]) + .is_err(), + "the publishing route must not accept caller-authored candidate JSON" + ); + } + #[test] fn corrupt_native_qualification_bytes_are_rejected_without_writing() { let output = tempfile::tempdir() diff --git a/src/daemon/production_harness/generation_retention_test.rs b/src/daemon/production_harness/generation_retention_test.rs index 8bb2294693..06709fd4a8 100644 --- a/src/daemon/production_harness/generation_retention_test.rs +++ b/src/daemon/production_harness/generation_retention_test.rs @@ -622,7 +622,7 @@ fn age_scope_for_reconciliation(scope: &Path) { async fn linked_worktree_scope_retention_crash_replay_and_pure_inventory_journey() { use super::semantic_activation_journey_test::{ evaluate_native_profile, installed_selection_material, seed_distribution_fixture, - selection, semantic_candidate, set_semantic_profile, wait_for_semantic_generation, + selection, set_semantic_profile, wait_for_semantic_generation, }; let fixture_root = std::env::var_os("TRACEDECAY_DISTRIBUTION_FASTEMBED_FIXTURE") @@ -689,18 +689,8 @@ async fn linked_worktree_scope_retention_crash_replay_and_pure_inventory_journey primary_vector.generation_id(), linked_vector.generation_id() ); - let primary_profile = evaluate_native_profile( - &harness, - &primary, - semantic_candidate(&primary_code, &primary_vector), - ) - .await; - let linked_profile = evaluate_native_profile( - &harness, - &linked, - semantic_candidate(&linked_code, &linked_vector), - ) - .await; + let primary_profile = evaluate_native_profile(&harness, &primary).await; + let linked_profile = evaluate_native_profile(&harness, &linked).await; let primary_selection = selection(primary_profile, &artifact_digest, &artifact_path); let linked_selection = selection(linked_profile, &artifact_digest, &artifact_path); set_semantic_profile( diff --git a/src/daemon/production_harness/semantic_activation_journey_test.rs b/src/daemon/production_harness/semantic_activation_journey_test.rs index 17a4faf898..0ee8157016 100644 --- a/src/daemon/production_harness/semantic_activation_journey_test.rs +++ b/src/daemon/production_harness/semantic_activation_journey_test.rs @@ -7,18 +7,8 @@ use std::time::Duration; use serde_json::{Value, json}; use tracedecay_application::ConfigurationSetRequestV1; use tracedecay_domain::configuration::{ConfigurationLayerIdV1, ConfigurationValueV1, SettingKey}; -use tracedecay_domain::{ - CalibrationProfileId, ComponentRevision, ManifestDigest, SemanticSearchIndexProfileV1, - VectorGenerationIdV1, canonical_sha256, -}; -use tracedecay_query::retrieval::semantic::SemanticCalibrationProfileV1; -use tracedecay_usecases::config::retrieval::{ - RetrievalCompatibilityPinsV1, SemanticCompatibilityPinsV1, SemanticResourceRequirementV1, -}; -use tracedecay_usecases::semantic_runtime::{ - SemanticEvaluationDiversityCandidateV1, SemanticEvaluationFusionCandidateV1, - SemanticEvaluationProfileCandidateV1, SemanticRuntimeStateV1, -}; +use tracedecay_domain::{ManifestDigest, VectorGenerationIdV1}; +use tracedecay_usecases::semantic_runtime::SemanticRuntimeStateV1; use tracedecay_usecases::store::vector_generations::{ GraphVectorGenerationStoreV1, PublishedVectorGenerationV1, }; @@ -228,127 +218,15 @@ pub(super) async fn wait_for_semantic_generation( .expect("production semantic generation did not publish") } -pub(super) fn semantic_candidate( - code: &tracedecay_code_index::production::CodeIndexPublishedGenerationV1, - vector: &PublishedVectorGenerationV1, -) -> SemanticEvaluationProfileCandidateV1 { - let material = - crate::search_eval::load_default_evaluated_profile_material(EVALUATED_PROFILE_ID) - .expect("checked-in evaluated profile material"); - let embedding = vector.embedding_key().embedding_key(); - let runtime_compatibility_digest = canonical_sha256(&( - "tracedecay.semantic-runtime-compatibility.v1", - &embedding.runtime_backend, - &embedding.runtime_build_revision, - embedding.device_class, - embedding.precision, - )) - .expect("runtime compatibility digest"); - let search_index_key = SemanticSearchIndexProfileV1::exact_flat_v1() - .and_then(|profile| profile.index_key()) - .expect("production exact-flat semantic index"); - // These bound the evaluation execution only. The accepted profile is - // rebound to the evaluator's exact current/10x observations before it is - // persisted or becomes activation-eligible. - let configured_limits = crate::config::SemanticResourceCeilings::default(); - let catalog = crate::semantic_code::production_fastembed_catalog(); - let cataloged_model = catalog - .get(crate::semantic_code::DEFAULT_FASTEMBED_MODEL_ID) - .expect("production catalog contains default model"); - let model_bytes = cataloged_model - .members - .get("model") - .expect("production model member") - .length; - let tokenizer_bytes = cataloged_model - .members - .get("tokenizer") - .expect("production tokenizer member") - .length; - let vector_generation_id = vector.generation_id().clone(); - let calibration = SemanticCalibrationProfileV1 { - calibration_profile_id: CalibrationProfileId::new("calibration.semantic.runtime.v1") - .expect("calibration profile id"), - cohort_digest: canonical_sha256(&( - "tracedecay.semantic.evaluation-calibration-cohort.v1", - code.manifest().generation_id.clone(), - vector.source_manifest_digest().clone(), - code.capability().manifest_digest.clone(), - vector.embedding_key().clone(), - vector_generation_id.clone(), - embedding.model_artifact_digest.clone(), - )) - .expect("calibration cohort digest"), - projection_key: vector.projection_key().clone(), - vector_generation: vector_generation_id.clone(), - capability_manifest_digest: code.capability().manifest_digest.clone(), - maximum_distance_micros: i64::MAX, - minimum_margin_micros: 0, - }; - SemanticEvaluationProfileCandidateV1 { - evaluated_profile_id: EVALUATED_PROFILE_ID.to_owned(), - profile: SemanticEvaluationFusionCandidateV1 { - profile_id: material.profile.profile_id.clone(), - calibrations: material.profile.calibrations.clone(), - score_domain_calibrations: material.profile.score_domain_calibrations.clone(), - weights_micros: material.profile.weights_micros.clone(), - diversity_policy_id: material.profile.diversity_policy_id.clone(), - rerank_policy_id: material.profile.rerank_policy_id.clone(), - retrieval_budget: material.profile.retrieval_budget, - }, - diversity: SemanticEvaluationDiversityCandidateV1 { - policy_id: material.diversity.policy_id.clone(), - per_source_namespace: material.diversity.per_source_namespace, - per_source_instance: material.diversity.per_source_instance, - per_repository: material.diversity.per_repository, - per_file: material.diversity.per_file, - per_session_or_thread: material.diversity.per_session_or_thread, - per_copy_cluster: material.diversity.per_copy_cluster, - per_evidence_role: material.diversity.per_evidence_role, - }, - rerank: None, - compatibility: RetrievalCompatibilityPinsV1 { - semantic: Some(SemanticCompatibilityPinsV1 { - implementation_revision: ComponentRevision::new("semantic.fastembed.production.v1") - .expect("semantic implementation revision"), - fusion_revision: ComponentRevision::new( - tracedecay_query::retrieval::QUERY_RANKING_REVISION_V1, - ) - .expect("fusion revision"), - artifact_manifest_digest: embedding.model_artifact_digest.clone(), - runtime_compatibility_digest, - projection: vector.embedding_key().clone(), - search_index_key, - vector_generation_id, - calibration, - resources: SemanticResourceRequirementV1 { - model_bytes, - tokenizer_bytes, - resident_bytes: configured_limits.max_resident_bytes, - threads: configured_limits.max_threads, - max_concurrent_sessions: configured_limits.max_concurrent_sessions, - batch_size: configured_limits.max_batch_size, - sequence_length: configured_limits.max_sequence_length, - load_deadline_ms: configured_limits.load_deadline_ms, - }, - }), - rerank: None, - }, - } -} - pub(super) async fn evaluate_native_profile( harness: &ProductionProjectCompositionHarnessV1, project: &Path, - candidate: SemanticEvaluationProfileCandidateV1, ) -> ManifestDigest { let resources = harness.resources.as_ref().expect("live harness"); - let evaluation_limits = candidate - .compatibility - .semantic - .as_ref() - .expect("semantic evaluation limits") - .resources; + let evaluation_limits = + crate::daemon::semantic_evaluation::daemon_semantic_evaluation_resource_requirement( + crate::config::SemanticResourceCeilings::default(), + ); let observed_at = tracedecay_domain::UtcMicros( i64::try_from( std::time::SystemTime::now() @@ -377,7 +255,7 @@ pub(super) async fn evaluate_native_profile( None, crate::daemon_contract::DaemonInvocationRequest::semantic_evaluate_and_publish( request_id.as_str(), - candidate, + EVALUATED_PROFILE_ID.to_owned(), observed_at, tracedecay_application::Deadline::new(tracedecay_domain::UtcMicros( observed_at.0 @@ -737,12 +615,7 @@ async fn public_semantic_activation_rollback_and_exact_retry_preserve_graph_auth first_vector.generation_id().clone(), )]; let graph_before_first_evaluation = graph_bytes(&harness, &project, &first_generation).await; - let first_profile = evaluate_native_profile( - &harness, - &project, - semantic_candidate(&first_code, &first_vector), - ) - .await; + let first_profile = evaluate_native_profile(&harness, &project).await; assert_eq!( graph_bytes(&harness, &project, &first_generation).await, graph_before_first_evaluation, @@ -813,12 +686,7 @@ async fn public_semantic_activation_rollback_and_exact_retry_preserve_graph_auth ), ]; let graph_before_second_evaluation = graph_bytes(&harness, &project, &generations).await; - let second_profile = evaluate_native_profile( - &harness, - &project, - semantic_candidate(&second_code, &second_vector), - ) - .await; + let second_profile = evaluate_native_profile(&harness, &project).await; assert_eq!( graph_bytes(&harness, &project, &generations).await, graph_before_second_evaluation, diff --git a/src/daemon/production_harness/semantic_availability_journey_test.rs b/src/daemon/production_harness/semantic_availability_journey_test.rs index db0350c66e..ab286d1f10 100644 --- a/src/daemon/production_harness/semantic_availability_journey_test.rs +++ b/src/daemon/production_harness/semantic_availability_journey_test.rs @@ -18,8 +18,7 @@ use serde_json::{Value, json}; use super::journey_test_support::{git, tool_payload}; use super::semantic_activation_journey_test::{ assert_semantic_probe_contribution, evaluate_native_profile, installed_selection_material, - seed_distribution_fixture, selection, semantic_candidate, set_semantic_profile, - wait_for_semantic_generation, + seed_distribution_fixture, selection, set_semantic_profile, wait_for_semantic_generation, }; use super::*; @@ -426,8 +425,7 @@ async fn retrieval_answers_before_activation_and_is_unchanged_by_live_semantic_a ); // ---- Phase 2: the real accepted-profile evaluation. ------------------- - let accepted_profile = - evaluate_native_profile(&harness, &project, semantic_candidate(&code, &vector)).await; + let accepted_profile = evaluate_native_profile(&harness, &project).await; assert_eq!( non_semantic_answers(&harness, &project).await, answers_before, diff --git a/src/daemon/semantic_evaluation.rs b/src/daemon/semantic_evaluation.rs index a3a14c5304..3761e3c131 100644 --- a/src/daemon/semantic_evaluation.rs +++ b/src/daemon/semantic_evaluation.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; use std::future::Future; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; #[cfg(test)] @@ -8,10 +8,17 @@ use std::time::Duration; use tokio::task::JoinHandle; use tracedecay_application::ResolvedScope; -use tracedecay_domain::CodeGenerationId; +use tracedecay_domain::{ + CalibrationProfileId, CodeGenerationId, ComponentRevision, SemanticSearchIndexProfileV1, + VectorGenerationIdV1, canonical_sha256, +}; +use tracedecay_query::retrieval::semantic::SemanticCalibrationProfileV1; use tracedecay_runtime_core::cancellation::CancellationToken; use crate::config::retrieval::RetrievalRuntimeCompatibilityV1; +use crate::config::retrieval::{ + RetrievalCompatibilityPinsV1, SemanticCompatibilityPinsV1, SemanticResourceRequirementV1, +}; use crate::search_eval::semantic_native::{ SemanticNativePendingReasonV1, SemanticNativeResourceProvenanceV1, SemanticNativeResourceSampleV1, SemanticNativeStageResultV1, SemanticProjectionCaseSampleV1, @@ -29,6 +36,9 @@ use tracedecay_usecases::semantic_runtime::{ SemanticEvaluationPublicationSnapshotV1, SemanticEvaluationSnapshotPortV1, SemanticRuntimeBackendErrorV1, SemanticRuntimeFuture, }; +use tracedecay_usecases::store::vector_generations::{ + GraphVectorGenerationStoreV1, PublishedVectorGenerationV1, +}; use super::code_index_scheduler::CodeIndexSchedulerRegistryV1; @@ -127,7 +137,7 @@ impl DaemonSemanticEvaluationControlV1 { .map_err(|_| SemanticActivationCoordinationErrorV1::Unavailable) } - async fn interruptible( + pub(super) async fn interruptible( &self, operation: impl Future, ) -> Result { @@ -198,6 +208,187 @@ fn coordination_error_from_runtime( } } +pub(super) async fn build_daemon_semantic_evaluation_candidate( + project_root: &Path, + scope: &ResolvedScope, + scheduler: &CodeIndexSchedulerRegistryV1, + evaluated_profile_id: &str, + configured_limits: crate::config::SemanticResourceCeilings, + control: Arc, +) -> Result { + control.checkpoint()?; + let snapshot = control + .interruptible(scheduler.semantic_evaluation_snapshot_for_scope(scope)) + .await? + .ok_or(SemanticActivationCoordinationErrorV1::Unavailable)?; + let serving = control + .interruptible(scheduler.serving_code_scope(project_root)) + .await? + .ok_or(SemanticActivationCoordinationErrorV1::Unavailable)?; + let code = serving + .serving_generation + .ok_or(SemanticActivationCoordinationErrorV1::Unavailable)?; + if code.manifest().generation_id != snapshot.source_generation + || code.projection().request().changes.manifest_digest != snapshot.source_manifest_digest + || code.manifest().snapshot_digest != snapshot.snapshot_digest + || code.capability().manifest_digest != snapshot.capability_manifest_digest + { + return Err(SemanticActivationCoordinationErrorV1::Conflict); + } + + let status = tracedecay_usecases::semantic_runtime::project_semantic_application_status( + project_root, + None, + ) + .ok_or(SemanticActivationCoordinationErrorV1::Unavailable)?; + let vector_generation_id = semantic_publication_generation(&status.state)?; + let provider = control + .interruptible(scheduler.semantic_vector_graph_provider(project_root)) + .await? + .ok_or(SemanticActivationCoordinationErrorV1::Unavailable)?; + let retained = control + .interruptible(provider.graph_for_generation(&code)) + .await? + .map_err(|_| SemanticActivationCoordinationErrorV1::Unavailable)?; + let store = + GraphVectorGenerationStoreV1::read_only_generation(&retained, &vector_generation_id) + .map_err(|_| SemanticActivationCoordinationErrorV1::Unavailable)? + .ok_or(SemanticActivationCoordinationErrorV1::Conflict)?; + let vector = control + .interruptible(store.generation(&vector_generation_id, Arc::clone(retained.cancellation()))) + .await? + .map_err(|_| SemanticActivationCoordinationErrorV1::Unavailable)? + .ok_or(SemanticActivationCoordinationErrorV1::Conflict)?; + if vector.source_generation() != &snapshot.source_generation + || vector.source_manifest_digest() != &snapshot.source_manifest_digest + { + return Err(SemanticActivationCoordinationErrorV1::Conflict); + } + daemon_semantic_evaluation_candidate(evaluated_profile_id, &code, &vector, configured_limits) +} + +pub(super) fn semantic_publication_generation( + state: &tracedecay_usecases::semantic_runtime::SemanticRuntimeStateV1, +) -> Result { + use tracedecay_usecases::semantic_runtime::{SemanticFallbackReasonV1, SemanticRuntimeStateV1}; + + match state { + SemanticRuntimeStateV1::Current { receipt } => Ok(receipt.activated_generation.clone()), + SemanticRuntimeStateV1::Degraded { + active_generation: Some(_), + reason: SemanticFallbackReasonV1::Stale, + } + | SemanticRuntimeStateV1::Rollback { .. } => { + Err(SemanticActivationCoordinationErrorV1::Conflict) + } + SemanticRuntimeStateV1::Degraded { + active_generation: Some(generation), + .. + } => Ok(generation.clone()), + _ => Err(SemanticActivationCoordinationErrorV1::Unavailable), + } +} + +fn daemon_semantic_evaluation_candidate( + evaluated_profile_id: &str, + code: &tracedecay_code_index::production::CodeIndexPublishedGenerationV1, + vector: &PublishedVectorGenerationV1, + configured_limits: crate::config::SemanticResourceCeilings, +) -> Result { + let material = + crate::search_eval::load_default_evaluated_profile_material(evaluated_profile_id) + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?; + let embedding = vector.embedding_key().embedding_key(); + let runtime_compatibility_digest = canonical_sha256(&( + "tracedecay.semantic-runtime-compatibility.v1", + &embedding.runtime_backend, + &embedding.runtime_build_revision, + embedding.device_class, + embedding.precision, + )) + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?; + let search_index_key = SemanticSearchIndexProfileV1::exact_flat_v1() + .and_then(|profile| profile.index_key()) + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?; + let resources = daemon_semantic_evaluation_resource_requirement(configured_limits); + let vector_generation_id = vector.generation_id().clone(); + let calibration = SemanticCalibrationProfileV1 { + calibration_profile_id: CalibrationProfileId::new("calibration.semantic.runtime.v1") + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?, + cohort_digest: canonical_sha256(&( + "tracedecay.semantic.evaluation-calibration-cohort.v1", + code.manifest().generation_id.clone(), + vector.source_manifest_digest().clone(), + code.capability().manifest_digest.clone(), + vector.embedding_key().clone(), + vector_generation_id.clone(), + embedding.model_artifact_digest.clone(), + )) + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?, + projection_key: vector.projection_key().clone(), + vector_generation: vector_generation_id.clone(), + capability_manifest_digest: code.capability().manifest_digest.clone(), + maximum_distance_micros: i64::MAX, + minimum_margin_micros: 0, + }; + Ok(SemanticEvaluationProfileCandidateV1 { + evaluated_profile_id: evaluated_profile_id.to_owned(), + profile: tracedecay_usecases::semantic_runtime::SemanticEvaluationFusionCandidateV1 { + profile_id: material.profile.profile_id.clone(), + calibrations: material.profile.calibrations.clone(), + score_domain_calibrations: material.profile.score_domain_calibrations.clone(), + weights_micros: material.profile.weights_micros.clone(), + diversity_policy_id: material.profile.diversity_policy_id.clone(), + rerank_policy_id: material.profile.rerank_policy_id.clone(), + retrieval_budget: material.profile.retrieval_budget, + }, + diversity: tracedecay_usecases::semantic_runtime::SemanticEvaluationDiversityCandidateV1 { + policy_id: material.diversity.policy_id.clone(), + per_source_namespace: material.diversity.per_source_namespace, + per_source_instance: material.diversity.per_source_instance, + per_repository: material.diversity.per_repository, + per_file: material.diversity.per_file, + per_session_or_thread: material.diversity.per_session_or_thread, + per_copy_cluster: material.diversity.per_copy_cluster, + per_evidence_role: material.diversity.per_evidence_role, + }, + rerank: None, + compatibility: RetrievalCompatibilityPinsV1 { + semantic: Some(SemanticCompatibilityPinsV1 { + implementation_revision: ComponentRevision::new("semantic.fastembed.production.v1") + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?, + fusion_revision: ComponentRevision::new( + tracedecay_query::retrieval::QUERY_RANKING_REVISION_V1, + ) + .map_err(|_| SemanticActivationCoordinationErrorV1::Rejected)?, + artifact_manifest_digest: embedding.model_artifact_digest.clone(), + runtime_compatibility_digest, + projection: vector.embedding_key().clone(), + search_index_key, + vector_generation_id, + calibration, + resources, + }), + rerank: None, + }, + }) +} + +pub(super) fn daemon_semantic_evaluation_resource_requirement( + configured_limits: crate::config::SemanticResourceCeilings, +) -> SemanticResourceRequirementV1 { + SemanticResourceRequirementV1 { + model_bytes: configured_limits.max_model_bytes, + tokenizer_bytes: configured_limits.max_tokenizer_bytes, + resident_bytes: configured_limits.max_resident_bytes, + threads: configured_limits.max_threads, + max_concurrent_sessions: configured_limits.max_concurrent_sessions, + batch_size: configured_limits.max_batch_size, + sequence_length: configured_limits.max_sequence_length, + load_deadline_ms: configured_limits.load_deadline_ms, + } +} + struct SemanticEvaluationWorkerV1 { control: Arc, handle: JoinHandle<()>, diff --git a/src/daemon/service/invocation/dispatch.rs b/src/daemon/service/invocation/dispatch.rs index f8bdf1224b..36d9dead14 100644 --- a/src/daemon/service/invocation/dispatch.rs +++ b/src/daemon/service/invocation/dispatch.rs @@ -800,7 +800,7 @@ impl DaemonInvocationService { .await } DaemonInvocationPayload::SemanticEvaluateAndPublish { - candidate, + evaluated_profile_id, observed_at, deadline, cancellation, @@ -808,7 +808,7 @@ impl DaemonInvocationService { self.execute_semantic_evaluation( project_root, request_id, - *candidate, + evaluated_profile_id, observed_at, deadline, cancellation, diff --git a/src/daemon/service/invocation/semantic_evaluation.rs b/src/daemon/service/invocation/semantic_evaluation.rs index d7ed84a129..fa53af9343 100644 --- a/src/daemon/service/invocation/semantic_evaluation.rs +++ b/src/daemon/service/invocation/semantic_evaluation.rs @@ -1,10 +1,9 @@ use super::*; use tracedecay_runtime_core::cancellation::CancellationToken; -#[derive(Clone, Copy)] -enum SemanticExecutionIntentV1 { - Qualify, - EvaluateAndPublish, +enum SemanticExecutionInputV1 { + Qualify(Box), + EvaluateAndPublish(String), } enum SemanticExecutionOutcomeV1 { @@ -106,12 +105,11 @@ impl DaemonInvocationService { self.execute_semantic_operation( project_root, request_id, - candidate, observed_at, deadline, cancellation, request_cancellation, - SemanticExecutionIntentV1::Qualify, + SemanticExecutionInputV1::Qualify(Box::new(candidate)), ) .await } @@ -120,7 +118,7 @@ impl DaemonInvocationService { &self, project_root: Option<&Path>, request_id: String, - candidate: tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1, + evaluated_profile_id: String, observed_at: UtcMicros, deadline: Deadline, cancellation: CancellationContext, @@ -129,12 +127,11 @@ impl DaemonInvocationService { self.execute_semantic_operation( project_root, request_id, - candidate, observed_at, deadline, cancellation, request_cancellation, - SemanticExecutionIntentV1::EvaluateAndPublish, + SemanticExecutionInputV1::EvaluateAndPublish(evaluated_profile_id), ) .await } @@ -144,12 +141,11 @@ impl DaemonInvocationService { &self, project_root: Option<&Path>, request_id: String, - candidate: tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1, observed_at: UtcMicros, deadline: Deadline, cancellation: CancellationContext, request_cancellation: CancellationToken, - intent: SemanticExecutionIntentV1, + input: SemanticExecutionInputV1, ) -> DaemonInvocationResponse { let control = SemanticInvocationControlV1::new(observed_at, deadline, cancellation); if let Some(problem) = semantic_execution_interruption(&control, &request_cancellation) { @@ -171,9 +167,9 @@ impl DaemonInvocationService { DaemonInvocationProblem::Unavailable, ); }; - let operation = match intent { - SemanticExecutionIntentV1::Qualify => None, - SemanticExecutionIntentV1::EvaluateAndPublish => { + let operation = match &input { + SemanticExecutionInputV1::Qualify(_) => None, + SemanticExecutionInputV1::EvaluateAndPublish(_) => { let operation = registered.semantic_operation.get().cloned(); if let Some(problem) = semantic_execution_interruption(&control, &request_cancellation) @@ -215,8 +211,10 @@ impl DaemonInvocationService { let scope = registered.scope.clone(); let scheduler = self.code_index_schedulers.clone(); let workers = Arc::clone(®istered.semantic_evaluation_workers); - let execution = match intent { - SemanticExecutionIntentV1::Qualify => { + let configuration = registered.runtime.client(); + let execution = match input { + SemanticExecutionInputV1::Qualify(candidate) => { + let candidate = *candidate; workers .execute(worker_deadline, request_cancellation, move |control| { let authority = @@ -268,7 +266,7 @@ impl DaemonInvocationService { }) .await } - SemanticExecutionIntentV1::EvaluateAndPublish => { + SemanticExecutionInputV1::EvaluateAndPublish(evaluated_profile_id) => { let Some(operation) = operation else { return DaemonInvocationResponse::problem( request_id, @@ -277,16 +275,29 @@ impl DaemonInvocationService { }; workers .execute(worker_deadline, request_cancellation, move |control| { - let snapshot = - crate::daemon::semantic_evaluation::DaemonSemanticEvaluationSnapshotAuthorityV1::new( + async move { + let configured = control + .interruptible(configuration.current()) + .await? + .map_err(|_| SemanticActivationCoordinationErrorV1::Unavailable)?; + let candidate = crate::daemon::semantic_evaluation::build_daemon_semantic_evaluation_candidate( + &canonical_root, + &scope, + &scheduler, + &evaluated_profile_id, + configured.config.semantic.resources, + Arc::clone(&control), + ) + .await?; + let snapshot = + crate::daemon::semantic_evaluation::DaemonSemanticEvaluationSnapshotAuthorityV1::new( canonical_root.clone(), scope, scheduler, candidate.clone(), control, ); - let authority = crate::daemon::semantic_evaluation::DaemonSemanticEvaluationPublicationAuthorityV1::new(snapshot); - async move { + let authority = crate::daemon::semantic_evaluation::DaemonSemanticEvaluationPublicationAuthorityV1::new(snapshot); operation .evaluate_and_publish_profile(&authority, &canonical_root, candidate) .await @@ -438,8 +449,20 @@ fn semantic_evaluation_response( | SemanticActivationCoordinationErrorV1::RejectedDetail(_)), )) => application_problem(request_id, semantic_evaluation_rejection_problem(&error)), Err(DaemonSemanticEvaluationExecutionErrorV1::Coordination( - SemanticActivationCoordinationErrorV1::Conflict - | SemanticActivationCoordinationErrorV1::Runtime(_) + SemanticActivationCoordinationErrorV1::Conflict, + )) => application_problem( + request_id, + ApplicationProblem::Conflict { + diagnostic: SafeDiagnostic { + code: "semantic_evaluation.conflict".to_owned(), + message: "The semantic evaluation target changed before publication".to_owned(), + }, + retry: RetryDirective::AfterRevalidate, + legal_actions: vec![tracedecay_application::LegalAction::Refresh], + }, + ), + Err(DaemonSemanticEvaluationExecutionErrorV1::Coordination( + SemanticActivationCoordinationErrorV1::Runtime(_) | SemanticActivationCoordinationErrorV1::Unavailable, )) => DaemonInvocationResponse::problem(request_id, DaemonInvocationProblem::Unavailable), } @@ -550,4 +573,37 @@ mod tests { other => panic!("expected application problem, got {other:?}"), } } + + #[test] + fn indexing_cannot_be_published_as_an_empty_semantic_candidate() { + let state = tracedecay_usecases::semantic_runtime::SemanticRuntimeStateV1::Indexing { + completed_units: 7, + total_units: 11, + }; + + assert_eq!( + crate::daemon::semantic_evaluation::semantic_publication_generation(&state), + Err(SemanticActivationCoordinationErrorV1::Unavailable) + ); + } + + #[test] + fn stale_publication_conflict_remains_typed_across_the_daemon_boundary() { + let response = semantic_evaluation_response( + "req-semantic-conflict".to_owned(), + Err( + crate::daemon::semantic_evaluation::DaemonSemanticEvaluationExecutionErrorV1::Coordination( + SemanticActivationCoordinationErrorV1::Conflict, + ), + ), + ); + + match response.outcome { + DaemonInvocationOutcome::ApplicationProblem { problem } => { + assert_eq!(problem.kind(), ApplicationProblemKind::Conflict); + assert_eq!(problem.retry(), RetryDirective::AfterRevalidate); + } + other => panic!("expected typed conflict, got {other:?}"), + } + } } diff --git a/src/daemon/service/invocation/tests/project_admission_tests.rs b/src/daemon/service/invocation/tests/project_admission_tests.rs index 2f7ce635fc..8b26a76b8b 100644 --- a/src/daemon/service/invocation/tests/project_admission_tests.rs +++ b/src/daemon/service/invocation/tests/project_admission_tests.rs @@ -2,37 +2,6 @@ use super::*; -fn semantic_evaluation_candidate() --> tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1 { - let material = crate::search_eval::load_default_evaluated_profile_material("query-fallback") - .expect("checked-in query fallback profile"); - tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1 { - evaluated_profile_id: "query-fallback".to_owned(), - profile: tracedecay_usecases::semantic_runtime::SemanticEvaluationFusionCandidateV1 { - profile_id: material.profile.profile_id.clone(), - calibrations: material.profile.calibrations.clone(), - score_domain_calibrations: material.profile.score_domain_calibrations.clone(), - weights_micros: material.profile.weights_micros.clone(), - diversity_policy_id: material.profile.diversity_policy_id.clone(), - rerank_policy_id: material.profile.rerank_policy_id.clone(), - retrieval_budget: material.profile.retrieval_budget, - }, - diversity: tracedecay_usecases::semantic_runtime::SemanticEvaluationDiversityCandidateV1 { - policy_id: material.diversity.policy_id.clone(), - per_source_namespace: material.diversity.per_source_namespace, - per_source_instance: material.diversity.per_source_instance, - per_repository: material.diversity.per_repository, - per_file: material.diversity.per_file, - per_session_or_thread: material.diversity.per_session_or_thread, - per_copy_cluster: material.diversity.per_copy_cluster, - per_evidence_role: material.diversity.per_evidence_role, - }, - rerank: None, - compatibility: - tracedecay_usecases::config::retrieval::RetrievalCompatibilityPinsV1::default(), - } -} - #[test] fn retained_pre_reservation_admission_preserves_cancellation_and_timeout() { assert!(retained_request_admission_problem(RequestAdmission::Admitted).is_none()); @@ -73,7 +42,7 @@ async fn project_quiescence_denies_semantic_and_git_cached_routes() { let requests = [ DaemonInvocationRequest::semantic_evaluate_and_publish( "request.quiesced-semantic", - semantic_evaluation_candidate(), + "query-fallback".to_owned(), now, deadline.clone(), CancellationContext::active("cancel.quiesced-semantic").expect("cancellation"), diff --git a/src/daemon_client.rs b/src/daemon_client.rs index 12f0ce2555..ffa8fc22d3 100644 --- a/src/daemon_client.rs +++ b/src/daemon_client.rs @@ -592,10 +592,10 @@ impl DaemonInvocationClient { pub async fn evaluate_and_publish_semantic_profile( &self, - candidate: tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1, + evaluated_profile_id: &str, ) -> crate::errors::Result { self.evaluate_and_publish_semantic_profile_until( - candidate, + evaluated_profile_id, SEMANTIC_EVALUATION_DISPATCH_DEADLINE_MICROS, ) .await @@ -603,7 +603,7 @@ impl DaemonInvocationClient { pub async fn evaluate_and_publish_semantic_profile_until( &self, - candidate: tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1, + evaluated_profile_id: &str, deadline_micros: i64, ) -> crate::errors::Result { let request_id = @@ -637,7 +637,7 @@ impl DaemonInvocationClient { .invoke( crate::daemon_contract::DaemonInvocationRequest::semantic_evaluate_and_publish( request_id.as_str(), - candidate, + evaluated_profile_id.to_owned(), observed_at, deadline, cancellation, diff --git a/src/daemon_contract.rs b/src/daemon_contract.rs index ead70fb02d..cb703a3f99 100644 --- a/src/daemon_contract.rs +++ b/src/daemon_contract.rs @@ -888,7 +888,7 @@ pub(crate) enum DaemonInvocationPayload { cancellation: CancellationContext, }, SemanticEvaluateAndPublish { - candidate: Box, + evaluated_profile_id: String, observed_at: UtcMicros, deadline: Deadline, cancellation: CancellationContext, @@ -1482,7 +1482,7 @@ impl DaemonInvocationRequest { pub(crate) fn semantic_evaluate_and_publish( request_id: impl Into, - candidate: tracedecay_usecases::semantic_runtime::SemanticEvaluationProfileCandidateV1, + evaluated_profile_id: String, observed_at: UtcMicros, deadline: Deadline, cancellation: CancellationContext, @@ -1493,7 +1493,7 @@ impl DaemonInvocationRequest { request_id: request_id.into(), delivery_route: None, payload: DaemonInvocationPayload::SemanticEvaluateAndPublish { - candidate: Box::new(candidate), + evaluated_profile_id, observed_at, deadline, cancellation, @@ -2218,12 +2218,22 @@ impl DaemonInvocationRequest { } } DaemonInvocationPayload::SemanticEvaluateAndPublish { - candidate, + evaluated_profile_id, observed_at, deadline, cancellation, + } => { + if evaluated_profile_id.trim() != evaluated_profile_id + || evaluated_profile_id.is_empty() + || evaluated_profile_id.len() > MAX_OPAQUE_HANDLE_BYTES + || observed_at.0 <= 0 + || deadline.expires_at.0 <= 0 + || cancellation.token_id.as_str().len() > MAX_OPAQUE_HANDLE_BYTES + { + return Err(DaemonInvocationProblem::InvalidRequest); + } } - | DaemonInvocationPayload::SemanticQualify { + DaemonInvocationPayload::SemanticQualify { candidate, observed_at, deadline, @@ -2538,6 +2548,27 @@ mod semantic_qualification_tests { assert!(wire.get("snapshot_digest").is_none()); } + #[test] + fn semantic_publication_wire_carries_only_the_daemon_owned_profile_selection() { + let request = DaemonInvocationRequest::semantic_evaluate_and_publish( + "request.semantic-evaluation.default-profile", + "hybrid-conservative".to_owned(), + UtcMicros(1_000), + Deadline::new(UtcMicros(2_000)).expect("deadline"), + CancellationContext::active("cancellation.semantic-evaluation.default-profile") + .expect("cancellation"), + ); + + assert_eq!(request.validate(), Ok(())); + let wire = serde_json::to_value(request).expect("semantic evaluation wire"); + assert_eq!(wire["operation"], "semantic_evaluate_and_publish"); + assert_eq!(wire["evaluated_profile_id"], "hybrid-conservative"); + assert!( + wire.get("candidate").is_none(), + "caller-authored candidate material must not cross the publishing wire" + ); + } + #[test] fn semantic_qualification_outcome_carries_one_compact_canonical_blob() { let response = DaemonInvocationResponse::with_outcome( diff --git a/tests/daemon_suite/advanced_workflow_journey/task_session.rs b/tests/daemon_suite/advanced_workflow_journey/task_session.rs index 13287430e4..219e0fc246 100644 --- a/tests/daemon_suite/advanced_workflow_journey/task_session.rs +++ b/tests/daemon_suite/advanced_workflow_journey/task_session.rs @@ -18,31 +18,24 @@ use tracedecay_domain::configuration::{ SEMANTIC_RUNTIME_SETTING_KEY, SettingKey, }; use tracedecay_domain::{ - AdmittedEmbeddingProjectionKeyV1, CalibrationProfileId, ComponentRevision, ManifestDigest, - ProjectId, RetrieverKind, SemanticSearchIndexProfileV1, TaskId, TemporalModeV1, UtcMicros, - VectorGenerationIdV1, WorkAttemptIdentityV1, canonical_sha256, + ManifestDigest, ProjectId, RetrieverKind, TaskId, TemporalModeV1, UtcMicros, + VectorGenerationIdV1, WorkAttemptIdentityV1, }; use tracedecay_global_db::configuration::semantic::{SemanticConfig, SemanticProfileSelection}; -use tracedecay_query::retrieval::semantic::SemanticCalibrationProfileV1; use tracedecay_sdk::client::Client; use tracedecay_sdk::operations::{ ApplicationConfigurationGet, ApplicationConfigurationObservedState, ApplicationConfigurationSet, WorkRetrieveEvidence, }; use tracedecay_semantic::{ - DEFAULT_FASTEMBED_MODEL_ID, LoadedSemanticArtifactV1, SemanticModelLifecycleOwnerV1, - SemanticModelLifecycleStateV1, SemanticResourceCeilings, -}; -use tracedecay_usecases::config::retrieval::{ - RetrievalCompatibilityPinsV1, SemanticCompatibilityPinsV1, SemanticResourceRequirementV1, + DEFAULT_FASTEMBED_MODEL_ID, SemanticModelLifecycleOwnerV1, SemanticModelLifecycleStateV1, + SemanticResourceCeilings, }; use tracedecay_usecases::retention::code_index_generations::{ DurablePublicationPointerV1, scoped_code_index_store_root, }; use tracedecay_usecases::semantic_runtime::{ - SemanticEvaluationDiversityCandidateV1, SemanticEvaluationFusionCandidateV1, - SemanticEvaluationProfileCandidateV1, SemanticFallbackReasonV1, SemanticRuntimeStateV1, - SemanticRuntimeStatusV1, + SemanticFallbackReasonV1, SemanticRuntimeStateV1, SemanticRuntimeStatusV1, }; use super::{ @@ -397,22 +390,7 @@ fn activate_evaluated_semantic_profile( project_id: &ProjectId, installed: &InstalledSemanticFixture, ) -> ManifestDigest { - let (code, vector_generation) = wait_for_semantic_generation(home, project); - let lifecycle = SemanticModelLifecycleOwnerV1::open_default( - tracedecay_semantic::default_lifecycle_root_in(&home.join(".tracedecay")), - ) - .expect("reopen installed semantic lifecycle"); - let resources = journey_semantic_resources(); - let projection = - LoadedSemanticArtifactV1::lifecycle_projection(&lifecycle, code.manifest(), resources) - .expect("derive the installed model's admitted projection"); - let candidate = semantic_candidate(&code, &projection, vector_generation, resources); - let candidate_path = home.join("semantic-evaluation-candidate.json"); - std::fs::write( - &candidate_path, - serde_json::to_vec_pretty(&candidate).expect("semantic candidate JSON"), - ) - .expect("write semantic evaluation candidate"); + let _ = wait_for_semantic_generation(home, project); let mut evaluator = std::process::Command::new(env!("CARGO_BIN_EXE_tracedecay-search-eval-direct")); common::apply_tracedecay_home_env(&mut evaluator, home); @@ -421,8 +399,8 @@ fn activate_evaluated_semantic_profile( let output = evaluator .args(["evaluate-and-publish", "--project-root"]) .arg(project) - .arg("--candidate") - .arg(&candidate_path) + .arg("--profile") + .arg(EVALUATED_PROFILE_ID) .current_dir(project) .output() .expect("start direct semantic evaluator"); @@ -604,157 +582,6 @@ fn read_active_code_generation( .ok() } -fn semantic_candidate( - code: &tracedecay_code_index::production::CodeIndexPublishedGenerationV1, - projection: &AdmittedEmbeddingProjectionKeyV1, - vector_generation_id: VectorGenerationIdV1, - evaluation_limits: SemanticResourceCeilings, -) -> SemanticEvaluationProfileCandidateV1 { - let material = - tracedecay::search_eval::load_default_evaluated_profile_material(EVALUATED_PROFILE_ID) - .expect("checked-in evaluated profile material"); - let embedding = projection.embedding_key(); - let runtime_compatibility_digest = canonical_sha256(&( - "tracedecay.semantic-runtime-compatibility.v1", - &embedding.runtime_backend, - &embedding.runtime_build_revision, - embedding.device_class, - embedding.precision, - )) - .expect("runtime compatibility digest"); - let calibration = SemanticCalibrationProfileV1 { - calibration_profile_id: CalibrationProfileId::new( - "calibration.semantic.advanced-workflow-journey.v1", - ) - .expect("calibration profile id"), - cohort_digest: canonical_sha256(&( - "tracedecay.semantic.advanced-workflow-journey.cohort.v1", - code.manifest().generation_id.clone(), - vector_generation_id.clone(), - code.capability().manifest_digest.clone(), - )) - .expect("calibration cohort digest"), - projection_key: projection.projection_key().clone(), - vector_generation: vector_generation_id.clone(), - capability_manifest_digest: code.capability().manifest_digest.clone(), - maximum_distance_micros: i64::MAX, - minimum_margin_micros: 0, - }; - SemanticEvaluationProfileCandidateV1 { - evaluated_profile_id: EVALUATED_PROFILE_ID.to_owned(), - profile: SemanticEvaluationFusionCandidateV1 { - profile_id: material.profile.profile_id.clone(), - calibrations: material.profile.calibrations.clone(), - score_domain_calibrations: material.profile.score_domain_calibrations.clone(), - weights_micros: material.profile.weights_micros.clone(), - diversity_policy_id: material.profile.diversity_policy_id.clone(), - rerank_policy_id: material.profile.rerank_policy_id.clone(), - retrieval_budget: material.profile.retrieval_budget, - }, - diversity: SemanticEvaluationDiversityCandidateV1 { - policy_id: material.diversity.policy_id.clone(), - per_source_namespace: material.diversity.per_source_namespace, - per_source_instance: material.diversity.per_source_instance, - per_repository: material.diversity.per_repository, - per_file: material.diversity.per_file, - per_session_or_thread: material.diversity.per_session_or_thread, - per_copy_cluster: material.diversity.per_copy_cluster, - per_evidence_role: material.diversity.per_evidence_role, - }, - rerank: None, - compatibility: RetrievalCompatibilityPinsV1 { - semantic: Some(SemanticCompatibilityPinsV1 { - implementation_revision: ComponentRevision::new("semantic.fastembed.production.v1") - .expect("semantic implementation revision"), - fusion_revision: ComponentRevision::new( - "fusion.semantic.advanced-workflow-journey.v1", - ) - .expect("fusion revision"), - artifact_manifest_digest: embedding.model_artifact_digest.clone(), - runtime_compatibility_digest, - projection: projection.clone(), - search_index_key: SemanticSearchIndexProfileV1::exact_flat_v1() - .and_then(|profile| profile.index_key()) - .expect("production exact-flat semantic index"), - vector_generation_id, - calibration, - resources: SemanticResourceRequirementV1 { - model_bytes: evaluation_limits.max_model_bytes, - tokenizer_bytes: evaluation_limits.max_tokenizer_bytes, - resident_bytes: evaluation_limits.max_resident_bytes, - threads: evaluation_limits.max_threads, - max_concurrent_sessions: evaluation_limits.max_concurrent_sessions, - batch_size: evaluation_limits.max_batch_size, - sequence_length: evaluation_limits.max_sequence_length, - load_deadline_ms: evaluation_limits.load_deadline_ms, - }, - }), - rerank: None, - }, - } -} - -/// Proves the evaluated profile is both selected through the public configuration -/// authority and ready through the mounted runtime authority before TaskSession -/// selection. TaskSession anchors deliberately retain only TaskSession provenance. -pub(super) fn wait_for_evaluated_semantic_profile_current( - home: &Path, - project: &Path, - client: &Client, -) { - let configured = client - .execute::( - &tracedecay_application::configuration::ConfigurationGetRequestV1 { - key: SettingKey::new(SEMANTIC_RUNTIME_SETTING_KEY) - .expect("semantic runtime setting key"), - }, - ) - .expect("read activated semantic runtime configuration through typed SDK") - .result; - let ConfigurationValueV1::Text(value) = &configured.effective_value else { - panic!( - "activated semantic runtime must retain its typed configuration text: {configured:?}" - ); - }; - let semantic_config: SemanticConfig = serde_json::from_str(value) - .unwrap_or_else(|error| panic!("decode activated semantic runtime configuration: {error}")); - let active_profile = semantic_config - .active_profile - .as_ref() - .expect("evaluated semantic profile must remain active through the typed SDK"); - assert_eq!( - active_profile.profile_id, EVALUATED_PROFILE_ID, - "the evaluated semantic profile selected through the typed SDK must remain active" - ); - - let deadline = Instant::now() + Duration::from_secs(120); - loop { - let status = semantic_runtime_status(home, project) - .unwrap_or_else(|| panic!("runtime returned an invalid semantic status")); - if let SemanticRuntimeStateV1::Current { receipt } = &status.state { - let runtime_configuration = status - .configuration - .as_ref() - .expect("ready semantic runtime must report its activation configuration"); - assert_eq!( - runtime_configuration.effective_behavior_digest, - configured.effective_behavior_digest, - "the runtime readiness receipt must authorize the exact evaluated configuration selected through the SDK" - ); - assert_eq!( - receipt.configuration, *runtime_configuration, - "the ready semantic receipt must retain the runtime's exact activation configuration" - ); - return; - } - assert!( - Instant::now() < deadline, - "timed out waiting for activated semantic runtime: {status:?}" - ); - std::thread::sleep(Duration::from_millis(250)); - } -} - fn semantic_runtime_status(home: &Path, project: &Path) -> Option { let value = serve_tool_call( home, From ae2d65b4db7b07a464e1d3346a15ac7af30de473 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 19:14:31 +0000 Subject: [PATCH 62/69] fix(query): bound lexical term sort cancellation --- .../lexical/projection/artifact/builder.rs | 167 +++++++++++++++--- .../candidate_producers.rs | 166 ++++++++++++++++- 2 files changed, 304 insertions(+), 29 deletions(-) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs index d0fe2caafd..106555d93b 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/builder.rs @@ -1,4 +1,4 @@ -use std::cmp::Reverse; +use std::cmp::{Ordering as CmpOrdering, Reverse}; use std::collections::BinaryHeap; use std::fs::File; use std::num::NonZeroUsize; @@ -61,6 +61,7 @@ const FINALIZATION_CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(1); // layout; general allocator metadata remains outside the ledger contract. const TERM_INSERT_PLAN_BYTES_PER_REF: usize = 3 * std::mem::size_of::(); const TERM_INSERT_CONTROL_INTERVAL: usize = 4_096; +const TERM_INSERT_SORT_RUN_ROWS: usize = 4_096; // This gate serializes mutation within the private-profile/stable-handle // authority. It denies ordinary second-connection DML, but is not a // cryptographic defense against malicious same-UID code that deliberately @@ -75,6 +76,54 @@ struct PreparedTermInsertRefV1<'a> { posting: &'a PreparedTermPostingV1, } +impl PreparedTermInsertRefV1<'_> { + fn key(&self) -> (&str, &str, i64) { + ( + self.posting.field.as_str(), + self.posting.term.as_str(), + self.document_id, + ) + } +} + +#[derive(Clone, Copy)] +struct PreparedTermMergeCursorV1<'a> { + entry: PreparedTermInsertRefV1<'a>, + run_index: usize, + run_offset: usize, +} + +impl PartialEq for PreparedTermMergeCursorV1<'_> { + fn eq(&self, other: &Self) -> bool { + self.entry.key() == other.entry.key() + && self.run_index == other.run_index + && self.run_offset == other.run_offset + } +} + +impl Eq for PreparedTermMergeCursorV1<'_> {} + +impl PartialOrd for PreparedTermMergeCursorV1<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PreparedTermMergeCursorV1<'_> { + fn cmp(&self, other: &Self) -> CmpOrdering { + self.entry + .key() + .cmp(&other.entry.key()) + .then_with(|| self.run_index.cmp(&other.run_index)) + .then_with(|| self.run_offset.cmp(&other.run_offset)) + } +} + +struct PreparedTermInsertPlanV1<'a> { + entries: Vec>, + merge_heap: BinaryHeap>>, +} + const BUILDER_GATE_TRIGGER_LAYOUT: [(&str, &str, &str); 14] = [ ("builder_gate_source_pages_insert", "source_pages", "INSERT"), ( @@ -1184,7 +1233,7 @@ impl CodeLexicalArtifactBuilderV1 { self.memory_budget_bytes, pages, )?; - let term_insert_plan = hotpath::measure_block!( + let mut term_insert_plan = hotpath::measure_block!( "query.artifact.batch.term_order", prepare_term_insert_plan( self.fixed_ledger_charge_bytes, @@ -1211,7 +1260,7 @@ impl CodeLexicalArtifactBuilderV1 { record_batch_row_metrics(pages); hotpath::measure_block!( "query.artifact.batch.postings", - append_prepared_postings(&transaction, pages, &term_insert_plan, control) + append_prepared_postings(&transaction, pages, &mut term_insert_plan, control) )?; record_batch_posting_metrics(pages); hotpath::measure_block!("query.artifact.batch.receipts", { @@ -1914,8 +1963,15 @@ fn prepared_term_row_count( } fn term_insert_plan_ledger_bytes(term_rows: usize) -> Result { - term_rows + let entries = term_rows .checked_mul(TERM_INSERT_PLAN_BYTES_PER_REF) + .ok_or_else(batch_ledger_overflow)?; + let runs = term_rows.div_ceil(TERM_INSERT_SORT_RUN_ROWS); + let merge_heap = runs + .checked_mul(std::mem::size_of::>()) + .ok_or_else(batch_ledger_overflow)?; + entries + .checked_add(merge_heap) .ok_or_else(batch_ledger_overflow) } @@ -1974,7 +2030,7 @@ fn prepare_term_insert_plan<'a>( memory_budget_bytes: usize, pages: &'a [PreparedCodeLexicalArtifactPageV1], control: &dyn CodeIndexExecutionControlV1, -) -> Result>, CodeLexicalArtifactErrorV1> { +) -> Result, CodeLexicalArtifactErrorV1> { checkpoint(control)?; let mut term_rows = 0usize; for page in pages { @@ -2005,8 +2061,8 @@ fn prepare_term_insert_plan<'a>( )); } - let mut plan = Vec::new(); - plan.try_reserve_exact(term_rows).map_err(|error| { + let mut entries = Vec::new(); + entries.try_reserve_exact(term_rows).map_err(|error| { CodeLexicalArtifactErrorV1::Io(format!( "bounded lexical term insert plan allocation failed: {error}" )) @@ -2015,7 +2071,7 @@ fn prepare_term_insert_plan<'a>( checkpoint(control)?; for document in &page.documents { checkpoint(control)?; - plan.extend( + entries.extend( document .term_postings .iter() @@ -2026,20 +2082,73 @@ fn prepare_term_insert_plan<'a>( ); } } - plan.sort_unstable_by(|left, right| { - ( - left.posting.field.as_str(), - left.posting.term.as_str(), - left.document_id, - ) - .cmp(&( - right.posting.field.as_str(), - right.posting.term.as_str(), - right.document_id, - )) - }); + for run in entries.chunks_mut(TERM_INSERT_SORT_RUN_ROWS) { + checkpoint(control)?; + run.sort_unstable_by(|left, right| left.key().cmp(&right.key())); + checkpoint(control)?; + } checkpoint(control)?; - Ok(plan) + + let run_count = term_rows.div_ceil(TERM_INSERT_SORT_RUN_ROWS); + let mut merge_heap = BinaryHeap::new(); + merge_heap.try_reserve_exact(run_count).map_err(|error| { + CodeLexicalArtifactErrorV1::Io(format!( + "bounded lexical term merge heap allocation failed: {error}" + )) + })?; + for (run_index, run) in entries.chunks(TERM_INSERT_SORT_RUN_ROWS).enumerate() { + checkpoint(control)?; + let Some(entry) = run.first().copied() else { + continue; + }; + merge_heap.push(Reverse(PreparedTermMergeCursorV1 { + entry, + run_index, + run_offset: 0, + })); + } + Ok(PreparedTermInsertPlanV1 { + entries, + merge_heap, + }) +} + +fn next_term_insert<'a>( + plan: &mut PreparedTermInsertPlanV1<'a>, +) -> Result>, CodeLexicalArtifactErrorV1> { + let Some(Reverse(cursor)) = plan.merge_heap.pop() else { + return Ok(None); + }; + let next_offset = cursor + .run_offset + .checked_add(1) + .ok_or_else(batch_ledger_overflow)?; + if next_offset < TERM_INSERT_SORT_RUN_ROWS { + let run_start = cursor + .run_index + .checked_mul(TERM_INSERT_SORT_RUN_ROWS) + .ok_or_else(batch_ledger_overflow)?; + let next_index = run_start + .checked_add(next_offset) + .ok_or_else(batch_ledger_overflow)?; + let run_end = run_start + .checked_add(TERM_INSERT_SORT_RUN_ROWS) + .ok_or_else(batch_ledger_overflow)? + .min(plan.entries.len()); + if next_index < run_end { + let entry = plan.entries.get(next_index).copied().ok_or_else(|| { + CodeLexicalArtifactErrorV1::Contract( + "lexical term merge cursor escaped its bounded run".to_owned(), + ) + })?; + plan.merge_heap.push(Reverse(PreparedTermMergeCursorV1 { + entry, + run_index: cursor.run_index, + run_offset: next_offset, + })); + } + } + Ok(Some(cursor.entry)) } fn sum_prepared_metric( @@ -2548,7 +2657,7 @@ fn append_prepared_imports( fn append_prepared_postings( transaction: &Transaction<'_>, pages: &[PreparedCodeLexicalArtifactPageV1], - term_insert_plan: &[PreparedTermInsertRefV1<'_>], + term_insert_plan: &mut PreparedTermInsertPlanV1<'_>, control: &dyn CodeIndexExecutionControlV1, ) -> Result<(), CodeLexicalArtifactErrorV1> { let mut term_statement = transaction @@ -2566,8 +2675,10 @@ fn append_prepared_postings( "INSERT INTO ngram_postings(page_ordinal, kind, ngram, documents, cardinality) VALUES (?1, ?2, ?3, ?4, ?5)", ) .map_err(sqlite_error)?; - for (ordinal, entry) in term_insert_plan.iter().enumerate() { - if ordinal.is_multiple_of(TERM_INSERT_CONTROL_INTERVAL) { + let expected_term_rows = term_insert_plan.entries.len(); + let mut inserted_term_rows = 0usize; + while let Some(entry) = next_term_insert(term_insert_plan)? { + if inserted_term_rows.is_multiple_of(TERM_INSERT_CONTROL_INTERVAL) { checkpoint(control)?; } term_statement @@ -2578,6 +2689,14 @@ fn append_prepared_postings( entry.posting.frequency ]) .map_err(sqlite_error)?; + inserted_term_rows = inserted_term_rows + .checked_add(1) + .ok_or_else(batch_ledger_overflow)?; + } + if inserted_term_rows != expected_term_rows { + return Err(CodeLexicalArtifactErrorV1::Contract( + "lexical term merge omitted planned postings".to_owned(), + )); } for page in pages { for document in &page.documents { 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 e1bf94398a..d24bda5283 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -3,7 +3,7 @@ use std::fmt; use std::io::Cursor; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use sha2::{Digest, Sha256}; @@ -176,6 +176,13 @@ struct CancelAtObservation { observations: AtomicUsize, } +struct CancelAtObservationWithJournalProbe { + cancellation_observation: usize, + observations: AtomicUsize, + journal_path: PathBuf, + journal_seen: AtomicBool, +} + struct CancelOnBackgroundObservation { caller: std::thread::ThreadId, } @@ -207,6 +214,23 @@ impl CancelAtObservation { } } +impl CancelAtObservationWithJournalProbe { + fn new(artifact_path: &Path, cancellation_observation: usize) -> Self { + let mut journal_path = artifact_path.as_os_str().to_owned(); + journal_path.push("-journal"); + Self { + cancellation_observation, + observations: AtomicUsize::new(0), + journal_path: PathBuf::from(journal_path), + journal_seen: AtomicBool::new(false), + } + } + + fn journal_seen(&self) -> bool { + self.journal_seen.load(Ordering::SeqCst) + } +} + impl CodeIndexExecutionControlV1 for CancelAtObservation { fn is_cancelled(&self) -> bool { let observations = self @@ -221,6 +245,25 @@ impl CodeIndexExecutionControlV1 for CancelAtObservation { } } +impl CodeIndexExecutionControlV1 for CancelAtObservationWithJournalProbe { + fn is_cancelled(&self) -> bool { + let observations = self + .observations + .fetch_add(1, Ordering::SeqCst) + .saturating_add(1); + if observations < self.cancellation_observation { + return false; + } + self.journal_seen + .store(self.journal_path.exists(), Ordering::SeqCst); + true + } + + fn is_deadline_exceeded(&self) -> bool { + false + } +} + #[derive(Default)] struct CancelAfterAcceptedPage { page_accepted: std::sync::atomic::AtomicBool, @@ -1775,6 +1818,7 @@ fn disk_artifact_term_insert_execution_is_monotone_by_primary_key() { #[test] fn disk_artifact_term_insert_plan_obeys_exact_memory_boundary_before_mutation() { const TERM_INSERT_PLAN_BYTES_PER_REF: usize = 3 * std::mem::size_of::(); + const TERM_INSERT_SORT_RUN_ROWS: usize = 4_096; let (fixture, pages, _) = real_verified_pages(); let pages = &pages[..1]; @@ -1802,9 +1846,16 @@ fn disk_artifact_term_insert_plan_obeys_exact_memory_boundary_before_mutation() .expect("count prepared term rows"); let term_rows = usize::try_from(term_rows).expect("term row count"); assert!(term_rows > 0, "fixture must emit term postings"); - let plan_ledger = term_rows + let entry_ledger = term_rows .checked_mul(TERM_INSERT_PLAN_BYTES_PER_REF) .expect("term plan ledger charge"); + let merge_heap_ledger = term_rows + .div_ceil(TERM_INSERT_SORT_RUN_ROWS) + .checked_mul(std::mem::size_of::<(i64, usize, usize, usize)>()) + .expect("term merge heap ledger charge"); + let plan_ledger = entry_ledger + .checked_add(merge_heap_ledger) + .expect("complete term plan ledger charge"); let exact_budget = fixed_ledger .checked_add(prepared_ledger) .and_then(|bytes| bytes.checked_add(plan_ledger)) @@ -1846,11 +1897,11 @@ fn disk_artifact_term_insert_plan_obeys_exact_memory_boundary_before_mutation() let mut interrupted = CodeLexicalArtifactBuilderV1::create(&interrupted_path, metadata.clone()) .expect("create interrupted term plan builder"); let documents = usize::try_from(prepared[0].chunk_count()).expect("prepared document count"); - // append entry + plan entry + page/document count pass + page/document - // collection pass + the post-sort checkpoint. + // Append entry + plan entry + both page/document passes + checkpoints + // before and after the single bounded run + the post-run checkpoint. let post_sort_observation = documents .checked_mul(2) - .and_then(|observations| observations.checked_add(5)) + .and_then(|observations| observations.checked_add(7)) .expect("post-sort observation"); let cancellation = CancelAtObservation::new(post_sort_observation); assert!(matches!( @@ -1896,6 +1947,111 @@ fn disk_artifact_term_insert_plan_obeys_exact_memory_boundary_before_mutation() assert_eq!(progress.next_page_ordinal, 1); } +#[test] +fn disk_artifact_term_run_sort_observes_cancellation_before_transaction_entry() { + const TERM_SORT_RUN_ROWS: usize = 4_096; + + let mut source = String::with_capacity(192 * 1024); + for ordinal in 0..768 { + source.push_str(&format!( + "export function ordered_symbol_{ordinal:04}(input_value: string) {{ const local_value_{ordinal:04} = input_value + 'term_{ordinal:04}'; return local_value_{ordinal:04}; }}\n" + )); + } + let fixture = real_lexical_source_fixture_from_sources(vec![( + "file.artifact.term-runs".to_owned(), + "src/term-runs.ts".to_owned(), + source.into_bytes(), + )]); + let (pages, _) = drain_verified_pages(&fixture, 128); + assert!( + pages.iter().all(|page| page.imports().is_empty()), + "term-run fixture must reach document writes without import checkpoints" + ); + let metadata = fixture.metadata; + let directory = tempfile::tempdir().expect("artifact tempdir"); + let probe_path = directory.path().join("term-run-probe.sqlite"); + let mut probe = CodeLexicalArtifactBuilderV1::create(&probe_path, metadata.clone()) + .expect("create term-run probe"); + let control = ArtifactControl { cancelled: false }; + let prepared = probe + .prepare_pages(&pages, &control) + .expect("prepare multi-run term batch"); + probe + .append_prepared_pages(&prepared, &control) + .expect("append term-run probe"); + let term_rows = rusqlite::Connection::open(&probe_path) + .expect("open term-run probe") + .query_row("SELECT COUNT(*) FROM term_postings", [], |row| { + row.get::<_, i64>(0) + }) + .expect("count term-run rows"); + let term_rows = usize::try_from(term_rows).expect("term-run row count"); + assert!( + term_rows > TERM_SORT_RUN_ROWS, + "fixture must require at least two bounded sort runs: {term_rows}" + ); + drop(probe); + + let interrupted_path = directory.path().join("term-run-interrupted.sqlite"); + let mut interrupted = CodeLexicalArtifactBuilderV1::create(&interrupted_path, metadata.clone()) + .expect("create interrupted term-run builder"); + let page_count = prepared.len(); + let document_count = prepared.iter().try_fold(0usize, |documents, page| { + usize::try_from(page.chunk_count()) + .ok() + .and_then(|page_documents| documents.checked_add(page_documents)) + }); + let document_count = document_count.expect("prepared document count"); + // Entry checkpoints plus both page/document passes consume + // 2 + 2*pages + 2*documents observations. The third later observation is + // the checkpoint before the second bounded sort run. With one monolithic + // sort it instead occurs after the first document row has opened SQLite's + // DELETE-mode rollback journal, making this regression non-vacuous. + let cancellation_observation = page_count + .checked_mul(2) + .and_then(|observations| { + document_count + .checked_mul(2) + .and_then(|documents| observations.checked_add(documents)) + }) + .and_then(|observations| observations.checked_add(5)) + .expect("second term sort run observation"); + let cancellation = + CancelAtObservationWithJournalProbe::new(&interrupted_path, cancellation_observation); + assert!(matches!( + interrupted.append_prepared_pages(&prepared, &cancellation), + Err(CodeLexicalArtifactErrorV1::Interrupted(_)) + )); + assert!( + !cancellation.journal_seen(), + "sort-scale cancellation must be observed before SQLite opens its rollback journal" + ); + assert_eq!( + interrupted + .progress() + .expect("progress after run-sort interruption") + .next_page_ordinal, + 0 + ); + assert_eq!(staged_row_cardinality(&interrupted_path), (0, 0)); + drop(interrupted); + + let mut resumed = CodeLexicalArtifactBuilderV1::open_or_resume_with_memory_budget_and_control( + &interrupted_path, + metadata, + CODE_LEXICAL_ARTIFACT_BUILD_MEMORY_BUDGET_BYTES_V1, + &control, + ) + .expect("reopen after run-sort interruption"); + assert_eq!( + resumed + .append_prepared_pages(&prepared, &control) + .expect("retry interrupted term runs") + .next_page_ordinal, + u64::try_from(prepared.len()).expect("prepared page count") + ); +} + #[test] fn disk_artifact_widened_reservation_commits_high_ngram_window_atomically() { const PRIOR_BUILD_BUDGET_BYTES: usize = 768 * 1024 * 1024; From 01f9f742adf995782b641d0e3103f939e9415c87 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 20:52:43 +0000 Subject: [PATCH 63/69] fix(index): report graph-off text freshness --- .../code_index_scheduler/query_runtime.rs | 6 ++-- src/daemon/code_index_scheduler/registry.rs | 29 ++++++++++++++++--- src/daemon/code_index_scheduler/tests.rs | 12 ++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/daemon/code_index_scheduler/query_runtime.rs b/src/daemon/code_index_scheduler/query_runtime.rs index f9b767297f..32ace18ff4 100644 --- a/src/daemon/code_index_scheduler/query_runtime.rs +++ b/src/daemon/code_index_scheduler/query_runtime.rs @@ -408,14 +408,16 @@ impl CodeIndexSchedulerRegistryV1 { None => (serving, true), }, None => { - if let Some(text) = self.latest_text_serving_for_scope(scope).await { + if let Some((text, current)) = + self.latest_text_serving_freshness_for_scope(scope).await + { return execute_query_search_on_text( self, scope, input, text, None, - true, + !current, graph_control, ) .await; diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 8994659639..1cce7c4195 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -4031,7 +4031,19 @@ impl CodeIndexSchedulerRegistryV1 { &self, scope: &tracedecay_application::ResolvedScope, ) -> Option { - let (root, scheduler, text_generation, wake, pending_wake) = { + self.latest_text_serving_freshness_for_scope(scope) + .await + .map(|(latest, _)| latest) + } + + /// Resolve the graph-independent text owner together with the freshness + /// decision made by the same scheduler observation. A ready text artifact + /// is not inherently stale merely because native graph activation is off. + pub(in crate::daemon) async fn latest_text_serving_freshness_for_scope( + &self, + scope: &tracedecay_application::ResolvedScope, + ) -> Option<(LatestCodeTextGenerationV1, bool)> { + let (root, scheduler, text_generation, wake, pending_wake, reconcile_in_progress) = { let mounted = self.mounted.lock().await; let (root, worktree) = unique_mounted_for_scope(&mounted, scope).unique()?; ( @@ -4040,6 +4052,7 @@ impl CodeIndexSchedulerRegistryV1 { Arc::clone(&worktree.text_generation), Arc::clone(&worktree.wake), Arc::clone(&worktree.pending_wake), + Arc::clone(&worktree.reconcile_in_progress), ) }; let scope = scope.clone(); @@ -4054,6 +4067,13 @@ impl CodeIndexSchedulerRegistryV1 { .filter(|latest| { latest.text_serving_is_ready() && text_matches_scope_identity(latest, &scope) })?; + if pending_wake.has_pending_arrival() + || reconcile_in_progress.load(Ordering::Acquire) != 0 + { + // The existing worker owns the freshness remedy. Re-requesting + // here would enqueue a redundant pass behind it. + return Some((latest, false)); + } let mut scheduler = match scheduler.try_lock() { Ok(scheduler) => scheduler, Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(), @@ -4063,17 +4083,18 @@ impl CodeIndexSchedulerRegistryV1 { &wake, CodeIndexCadenceTriggerV1::BusyFollowUp, ); - return Some(latest); + return Some((latest, false)); } }; - if scheduler.request_fresh_for_query_background() { + let current = !scheduler.request_fresh_for_query_background(); + if !current { Self::note_wake( &pending_wake, &wake, CodeIndexCadenceTriggerV1::QueryAdmission, ); } - Some(latest) + Some((latest, current)) }) .await .ok() diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 88c0f2bd97..6c87ab2034 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -10061,6 +10061,10 @@ async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() } } }; + assert!( + executed.served_stale, + "an explicit overflow keeps the currently served text generation stale until reconcile settles" + ); let overflow_deadline = std::time::Instant::now() + Duration::from_secs(10); loop { let overflow_settled = { @@ -10185,6 +10189,14 @@ async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() .phase, crate::dashboard::code_index_freshness_api::CodeIndexBuildPhaseV1::Ready ); + let current = registry + .execute_query_search(&scope, core_search_request("alpha_0000")) + .await + .expect("settled graph-off text query"); + assert!( + !current.served_stale, + "a reconciled graph-off text owner must report current exact and lexical coverage" + ); let query_scope = CodeQueryScope::new(executed.generation.clone(), None).expect("query scope"); let exact_operation = From 468655947102a7a6af9abfcce1bbbd5ac95949a4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 21:00:25 +0000 Subject: [PATCH 64/69] feat(index): persist text result display identity --- .../src/production/lexical_page_source.rs | 188 +++++++++++++++++- .../src/production/mod.rs | 3 +- .../src/retrieval/lexical/projection.rs | 16 ++ .../lexical/projection/artifact/format.rs | 15 +- .../lexical/projection/artifact/prepared.rs | 13 +- .../lexical/projection/artifact/reader.rs | 3 + .../candidate_producers.rs | 30 ++- 7 files changed, 252 insertions(+), 16 deletions(-) diff --git a/crates/tracedecay-code-index/src/production/lexical_page_source.rs b/crates/tracedecay-code-index/src/production/lexical_page_source.rs index 193c80addc..6af343e72c 100644 --- a/crates/tracedecay-code-index/src/production/lexical_page_source.rs +++ b/crates/tracedecay-code-index/src/production/lexical_page_source.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeMap, io::{BufRead, BufReader, Read, Seek, SeekFrom}, num::NonZeroUsize, }; @@ -18,6 +19,9 @@ const SOURCE_DIGEST_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-source.v1\0"; const IMPORT_DICTIONARY_DIGEST_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-import-dictionary.v1\0"; const IMPORT_RECORD_DOMAIN: &[u8] = b"import\0"; const SOURCE_CHAIN_RECORD_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-source-chain.v1\0"; +const SYMBOL_DISPLAY_RECORD_DOMAIN: &[u8] = b"symbol-display\0"; +const SOURCE_SYMBOL_DISPLAY_CHAIN_RECORD_DOMAIN: &[u8] = + b"tracedecay.sealed-lexical-symbol-display-chain.v1\0"; const IMPORT_DICTIONARY_CHAIN_RECORD_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-import-dictionary-chain.v1\0"; const CURSOR_DIGEST_DOMAIN: &[u8] = b"tracedecay.sealed-lexical-cursor.v1\0"; @@ -231,6 +235,42 @@ impl VerifiedSealedLexicalCursorV1 { } } +/// Compact parser-attested display identity for one symbol-backed chunk. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct VerifiedSealedLexicalSymbolDisplayV1 { + occurrence: SymbolOccurrenceId, + simple_name: String, + qualified_name: String, + kind: String, +} + +impl VerifiedSealedLexicalSymbolDisplayV1 { + pub fn occurrence(&self) -> &SymbolOccurrenceId { + &self.occurrence + } + + pub fn simple_name(&self) -> &str { + &self.simple_name + } + + pub fn qualified_name(&self) -> &str { + &self.qualified_name + } + + pub fn kind(&self) -> &str { + &self.kind + } + + pub fn retained_owned_bytes(&self) -> usize { + self.occurrence + .as_str() + .len() + .saturating_add(self.simple_name.capacity()) + .saturating_add(self.qualified_name.capacity()) + .saturating_add(self.kind.capacity()) + } +} + /// One bounded page of parser-backed, sanitized search chunks. #[derive(Debug)] pub struct VerifiedSealedLexicalPageV1 { @@ -243,6 +283,7 @@ pub struct VerifiedSealedLexicalPageV1 { cumulative_digest: ManifestDigest, next_cursor: VerifiedSealedLexicalCursorV1, chunks: Vec, + symbol_displays: Vec>, imports: Vec, previous_cursor: VerifiedSealedLexicalCursorV1, } @@ -288,6 +329,14 @@ impl VerifiedSealedLexicalPageV1 { self.chunks.capacity() } + pub fn symbol_displays(&self) -> &[Option] { + &self.symbol_displays + } + + pub fn symbol_display_capacity(&self) -> usize { + self.symbol_displays.capacity() + } + pub fn imports(&self) -> &[CodeIndexImportEvidenceV1] { &self.imports } @@ -324,7 +373,12 @@ impl VerifiedSealedLexicalPageV1 { let mut cumulative_digest = self.previous_cursor.cumulative_digest.clone(); let mut import_dictionary_digest = self.previous_cursor.import_dictionary_digest.clone(); let mut payload_bytes = 0u64; - for admitted in &self.chunks { + if self.symbol_displays.len() != self.chunks.len() { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical symbol-display cardinality does not match its chunks".to_owned(), + )); + } + for (admitted, display) in self.chunks.iter().zip(&self.symbol_displays) { let serialized = serde_json::to_vec(admitted.chunk()).map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( "sealed lexical chunk serialization failed: {error}" @@ -333,6 +387,27 @@ impl VerifiedSealedLexicalPageV1 { hash_record(&mut page_hasher, &serialized)?; cumulative_digest = advance_digest(&cumulative_digest, SOURCE_CHAIN_RECORD_DOMAIN, &serialized)?; + match (&admitted.chunk().anchor.symbol_occurrence_id, display) { + (Some(occurrence), Some(display)) if occurrence == display.occurrence() => { + let serialized_display = serde_json::to_vec(display).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed lexical symbol display serialization failed: {error}" + )) + })?; + hash_symbol_display_record(&mut page_hasher, &serialized_display)?; + cumulative_digest = advance_digest( + &cumulative_digest, + SOURCE_SYMBOL_DISPLAY_CHAIN_RECORD_DOMAIN, + &serialized_display, + )?; + } + (None, None) => {} + _ => { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical symbol display does not match its chunk anchor".to_owned(), + )); + } + } payload_bytes = payload_bytes .checked_add(u64::try_from(serialized.len()).map_err(|_| { CodeIndexProductionErrorV1::Contract( @@ -534,12 +609,28 @@ impl VerifiedSealedLexicalPageV1 { .saturating_add(chunk.sanitized_text.as_str().len()) }, ); + let symbol_display_bytes = self.symbol_displays.iter().fold( + self.symbol_displays + .capacity() + .saturating_mul(std::mem::size_of::< + Option, + >()), + |bytes, display| { + bytes.saturating_add(display.as_ref().map_or( + 0, + VerifiedSealedLexicalSymbolDisplayV1::retained_owned_bytes, + )) + }, + ); self.imports.iter().fold( - chunk_bytes.saturating_add(digest_bytes).saturating_add( - self.imports - .capacity() - .saturating_mul(std::mem::size_of::()), - ), + chunk_bytes + .saturating_add(symbol_display_bytes) + .saturating_add(digest_bytes) + .saturating_add( + self.imports + .capacity() + .saturating_mul(std::mem::size_of::()), + ), |bytes, evidence| { bytes .saturating_add(evidence.logical_path.capacity()) @@ -719,6 +810,7 @@ pub enum VerifiedSealedLexicalPageBatchReadV1 { struct PendingSealedLexicalPageV1 { chunks: Vec, page_bytes: usize, + symbol_displays: Vec>, imports: Vec, import_bytes: usize, cursor: VerifiedSealedLexicalCursorV1, @@ -1252,6 +1344,8 @@ impl VerifiedSealedLexicalPageSourceV1 { let mut page_hasher = page_hasher(cursor.next_page_ordinal); let mut chunks = Vec::new(); let mut page_bytes = 0usize; + let mut symbol_displays = Vec::new(); + let mut symbol_display_bytes = 0usize; let mut imports = Vec::new(); let mut import_bytes = 0usize; @@ -1279,13 +1373,43 @@ impl VerifiedSealedLexicalPageSourceV1 { } while chunk_ordinal < admitted.chunks.len() { checkpoint(control)?; - let serialized = serde_json::to_vec(admitted.chunks[chunk_ordinal].chunk()) + let chunk = admitted.chunks[chunk_ordinal].chunk(); + let display = match chunk.anchor.symbol_occurrence_id.as_ref() { + Some(occurrence) => Some( + admitted + .symbol_displays + .get(occurrence) + .cloned() + .ok_or_else(|| { + CodeIndexProductionErrorV1::Contract( + "sealed lexical symbol chunk has no parser-attested display identity" + .to_owned(), + ) + })?, + ), + None => None, + }; + let serialized_display = display + .as_ref() + .map(serde_json::to_vec) + .transpose() .map_err(|error| { CodeIndexProductionErrorV1::Contract(format!( - "sealed lexical chunk serialization failed: {error}" + "sealed lexical symbol display serialization failed: {error}" )) })?; - if serialized.len() > self.maximum_page_bytes { + let serialized = serde_json::to_vec(chunk).map_err(|error| { + CodeIndexProductionErrorV1::Contract(format!( + "sealed lexical chunk serialization failed: {error}" + )) + })?; + let next_symbol_display_bytes = symbol_display_bytes + .saturating_add(serialized_display.as_ref().map_or(0, Vec::len)); + if serialized + .len() + .saturating_add(serialized_display.as_ref().map_or(0, Vec::len)) + > self.maximum_page_bytes + { return Err(CodeIndexProductionErrorV1::Contract( "one admitted lexical chunk exceeds the page byte bound".to_owned(), )); @@ -1293,6 +1417,7 @@ impl VerifiedSealedLexicalPageSourceV1 { if (!chunks.is_empty() || !imports.is_empty()) && (chunks.len() == self.maximum_page_chunks || page_bytes + .saturating_add(next_symbol_display_bytes) .saturating_add(import_bytes) .saturating_add(serialized.len()) > self.maximum_page_bytes) @@ -1302,6 +1427,7 @@ impl VerifiedSealedLexicalPageSourceV1 { PendingSealedLexicalPageV1 { chunks, page_bytes, + symbol_displays, imports, import_bytes, cursor, @@ -1315,12 +1441,22 @@ impl VerifiedSealedLexicalPageSourceV1 { SOURCE_CHAIN_RECORD_DOMAIN, &serialized, )?; + if let Some(serialized_display) = serialized_display.as_deref() { + hash_symbol_display_record(&mut page_hasher, serialized_display)?; + cursor.cumulative_digest = advance_digest( + &cursor.cumulative_digest, + SOURCE_SYMBOL_DISPLAY_CHAIN_RECORD_DOMAIN, + serialized_display, + )?; + } page_bytes = page_bytes.checked_add(serialized.len()).ok_or_else(|| { CodeIndexProductionErrorV1::Contract( "sealed lexical page byte count overflowed".to_owned(), ) })?; + symbol_display_bytes = next_symbol_display_bytes; chunks.push(admitted.chunks[chunk_ordinal].clone()); + symbol_displays.push(display); chunk_ordinal += 1; cursor.next_chunk_ordinal = u64::try_from(chunk_ordinal).map_err(|_| { CodeIndexProductionErrorV1::Contract( @@ -1333,6 +1469,7 @@ impl VerifiedSealedLexicalPageSourceV1 { PendingSealedLexicalPageV1 { chunks, page_bytes, + symbol_displays, imports, import_bytes, cursor, @@ -1366,6 +1503,7 @@ impl VerifiedSealedLexicalPageSourceV1 { } if (!chunks.is_empty() || !imports.is_empty()) && page_bytes + .saturating_add(symbol_display_bytes) .saturating_add(import_bytes) .saturating_add(serialized.len()) > self.maximum_page_bytes @@ -1375,6 +1513,7 @@ impl VerifiedSealedLexicalPageSourceV1 { PendingSealedLexicalPageV1 { chunks, page_bytes, + symbol_displays, imports, import_bytes, cursor, @@ -1423,6 +1562,7 @@ impl VerifiedSealedLexicalPageSourceV1 { PendingSealedLexicalPageV1 { chunks, page_bytes, + symbol_displays, imports, import_bytes, cursor, @@ -1438,6 +1578,7 @@ impl VerifiedSealedLexicalPageSourceV1 { PendingSealedLexicalPageV1 { chunks, page_bytes, + symbol_displays, imports, import_bytes, cursor, @@ -1502,12 +1643,30 @@ impl VerifiedSealedLexicalPageSourceV1 { } let exact_authority = ExactExtractionAuthorityV1::restore(&file.artifacts.chunks) .map_err(CodeIndexProductionErrorV1::Chunk)?; + let mut symbol_displays = BTreeMap::new(); + for symbol in &file.artifacts.symbols { + let display = VerifiedSealedLexicalSymbolDisplayV1 { + occurrence: symbol.occurrence.clone(), + simple_name: symbol.simple_name.clone(), + qualified_name: symbol.qualified_name.clone(), + kind: symbol.kind.clone(), + }; + if symbol_displays + .insert(symbol.occurrence.clone(), display) + .is_some() + { + return Err(CodeIndexProductionErrorV1::Contract( + "sealed lexical file contains duplicate symbol display identities".to_owned(), + )); + } + } let imports = file.artifacts.imports; let chunks = exact_authority .admit_all(file.artifacts.chunks.chunks) .map_err(CodeIndexProductionErrorV1::Chunk)?; Ok(AdmittedSealedLexicalFileV1 { chunks, + symbol_displays, imports, next_file_offset, }) @@ -1538,6 +1697,7 @@ impl VerifiedSealedLexicalPageSourceV1 { let PendingSealedLexicalPageV1 { chunks, page_bytes, + symbol_displays, imports, import_bytes, mut cursor, @@ -1613,6 +1773,7 @@ impl VerifiedSealedLexicalPageSourceV1 { cumulative_digest: cursor.cumulative_digest.clone(), next_cursor: cursor.clone(), chunks, + symbol_displays, imports, previous_cursor: previous_cursor.clone(), }; @@ -1625,6 +1786,7 @@ impl VerifiedSealedLexicalPageSourceV1 { #[derive(Debug)] struct AdmittedSealedLexicalFileV1 { chunks: Vec, + symbol_displays: BTreeMap, imports: Vec, next_file_offset: u64, } @@ -2485,6 +2647,14 @@ fn hash_import_record(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), CodeIndex hash_record(hasher, bytes) } +fn hash_symbol_display_record( + hasher: &mut Sha256, + bytes: &[u8], +) -> Result<(), CodeIndexProductionErrorV1> { + hasher.update(SYMBOL_DISPLAY_RECORD_DOMAIN); + hash_record(hasher, bytes) +} + fn hash_record(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), CodeIndexProductionErrorV1> { let byte_len = u64::try_from(bytes.len()).map_err(|_| { CodeIndexProductionErrorV1::Contract("sealed lexical digest record exceeds u64".to_owned()) diff --git a/crates/tracedecay-code-index/src/production/mod.rs b/crates/tracedecay-code-index/src/production/mod.rs index 6a734b309f..34f46dbc9f 100644 --- a/crates/tracedecay-code-index/src/production/mod.rs +++ b/crates/tracedecay-code-index/src/production/mod.rs @@ -79,7 +79,8 @@ pub use lexical_page_source::{ VerifiedSealedLexicalCursorV1, VerifiedSealedLexicalPageBatchBoundsV1, VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, - VerifiedSealedLexicalSourceReceiptV1, VerifiedSealedTextGenerationMetadataV1, + VerifiedSealedLexicalSourceReceiptV1, VerifiedSealedLexicalSymbolDisplayV1, + VerifiedSealedTextGenerationMetadataV1, }; mod sealed_codec; pub use sealed_codec::{ diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection.rs b/crates/tracedecay-query/src/retrieval/lexical/projection.rs index 974bb80703..a3d2377c10 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection.rs @@ -132,6 +132,9 @@ struct ProjectedChunkV1 { exact_terms: Vec, sanitized_text: BoundedSanitizedText, logical_path: String, + symbol_simple_name: Option, + symbol_qualified_name: Option, + symbol_kind: Option, field_lengths: BTreeMap, normalized_text: String, } @@ -150,6 +153,9 @@ impl ProjectedChunkV1 { chunk.exact_terms, chunk.sanitized_text, logical_path, + None, + None, + None, normalized_text, fields, ) @@ -161,6 +167,7 @@ impl ProjectedChunkV1 { fn from_ref( chunk: &CodeSearchChunkV1, logical_path: String, + display: Option<&tracedecay_code_index::production::VerifiedSealedLexicalSymbolDisplayV1>, ) -> (Self, BTreeMap>) { let fields = Self::projected_fields(chunk, &logical_path); let normalized_text = normalize_lexical(chunk.sanitized_text.as_str()); @@ -171,6 +178,9 @@ impl ProjectedChunkV1 { chunk.exact_terms.clone(), chunk.sanitized_text.clone(), logical_path, + display.map(|display| display.simple_name().to_owned()), + display.map(|display| display.qualified_name().to_owned()), + display.map(|display| display.kind().to_owned()), normalized_text, fields, ) @@ -244,6 +254,9 @@ impl ProjectedChunkV1 { exact_terms: Vec, sanitized_text: BoundedSanitizedText, logical_path: String, + symbol_simple_name: Option, + symbol_qualified_name: Option, + symbol_kind: Option, normalized_text: String, fields: BTreeMap>, ) -> (Self, BTreeMap>) { @@ -259,6 +272,9 @@ impl ProjectedChunkV1 { exact_terms, sanitized_text, logical_path, + symbol_simple_name, + symbol_qualified_name, + symbol_kind, field_lengths, normalized_text, }, diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs index 4bad03c835..8abe1cba0b 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/format.rs @@ -27,8 +27,10 @@ use super::CodeLexicalArtifactErrorV1; // source-page Roaring bitmap shards. Revision 8 adds source-page receipts for // every append-only base section so sealing and reopening need not rescan the // relational base after the private builder connection has admitted it. -pub(super) const CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1: u32 = 8; -const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"tracedecay.code-lexical-artifact.v8\0"; +// Revision 9 persists parser-attested symbol display identity with each row so +// graph-independent result hydration never needs the full sealed generation. +pub(super) const CODE_LEXICAL_ARTIFACT_FORMAT_REVISION_V1: u32 = 9; +const ARTIFACT_DIGEST_DOMAIN: &[u8] = b"tracedecay.code-lexical-artifact.v9\0"; const REQUIRED_ARTIFACT_INDEXES_V8: [(&str, &str, &[&str]); 7] = [ ("rows", "rows_by_chunk", &["chunk_id"]), ( @@ -764,6 +766,9 @@ pub struct CodeLexicalArtifactOccurrenceV1 { pub source_span: SourceSpan, pub logical_path: String, pub sanitized_text: BoundedSanitizedText, + pub simple_name: Option, + pub qualified_name: Option, + pub kind: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -782,6 +787,9 @@ pub(super) struct ArtifactRowV1 { pub exact_terms: Vec, pub sanitized_text: BoundedSanitizedText, pub logical_path: String, + pub symbol_simple_name: Option, + pub symbol_qualified_name: Option, + pub symbol_kind: Option, pub field_lengths: BTreeMap, pub normalized_text: String, } @@ -795,6 +803,9 @@ impl From for ArtifactRowV1 { exact_terms: row.exact_terms, sanitized_text: row.sanitized_text, logical_path: row.logical_path, + symbol_simple_name: row.symbol_simple_name, + symbol_qualified_name: row.symbol_qualified_name, + symbol_kind: row.symbol_kind, field_lengths: row.field_lengths, normalized_text: row.normalized_text, } diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs index 268498ac11..413d3f20ec 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/prepared.rs @@ -138,7 +138,14 @@ pub(super) fn prepare_page( let mut documents = Vec::with_capacity(page.chunks().len()); let mut ngram_documents = BTreeMap::<(i64, i64), RoaringBitmap>::new(); let mut logical_ngram_postings = 0usize; - for (offset, admitted) in page.chunks().iter().enumerate() { + if page.symbol_displays().len() != page.chunks().len() { + return Err(CodeLexicalArtifactErrorV1::Corrupt( + "sealed lexical symbol-display cardinality does not match its chunks".to_owned(), + )); + } + for (offset, (admitted, display)) in + page.chunks().iter().zip(page.symbol_displays()).enumerate() + { checkpoint(control)?; let document = first_document .checked_add(u64::try_from(offset).map_err(contract_number)?) @@ -151,6 +158,7 @@ pub(super) fn prepare_page( metadata, i64::try_from(document).map_err(contract_number)?, admitted.chunk(), + display.as_ref(), control, )?; let document = u32::try_from(prepared.document_id).map_err(contract_number)?; @@ -343,6 +351,7 @@ fn prepare_document( metadata: &CodeLexicalProjectionMetadataV1, document_id: i64, chunk: &tracedecay_domain::CodeSearchChunkV1, + display: Option<&tracedecay_code_index::production::VerifiedSealedLexicalSymbolDisplayV1>, control: &dyn CodeIndexExecutionControlV1, ) -> Result<(PreparedDocumentV1, Vec<(i64, i64)>), CodeLexicalArtifactErrorV1> { u32::try_from(document_id).map_err(|_| { @@ -365,7 +374,7 @@ fn prepare_document( chunk.anchor.file_occurrence_id )) })?; - let (row, fields) = ProjectedChunkV1::from_ref(chunk, logical_path); + let (row, fields) = ProjectedChunkV1::from_ref(chunk, logical_path, display); let mut term_postings = Vec::new(); for (field, terms) in &fields { checkpoint(control)?; diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs index b5230ca8f5..7acc2b5019 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs @@ -2292,6 +2292,9 @@ fn row_occurrence(row: ArtifactRowV1) -> CodeLexicalArtifactOccurrenceV1 { source_span: row.anchor.source_span, logical_path: row.logical_path, sanitized_text: row.sanitized_text, + simple_name: row.symbol_simple_name, + qualified_name: row.symbol_qualified_name, + kind: row.symbol_kind, } } 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 d24bda5283..6b62ccb5da 100644 --- a/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs +++ b/crates/tracedecay-query/tests/search_quality_suite/candidate_producers.rs @@ -22,7 +22,7 @@ use tracedecay_code_index::production::{ CodeIndexRepositoryParseIdentityV1, VerifiedSealedLexicalPageBatchBoundsV1, VerifiedSealedLexicalPageBatchReadV1, VerifiedSealedLexicalPageReadV1, VerifiedSealedLexicalPageSourceV1, VerifiedSealedLexicalPageV1, - VerifiedSealedLexicalSourceReceiptV1, + VerifiedSealedLexicalSourceReceiptV1, VerifiedSealedLexicalSymbolDisplayV1, }; use tracedecay_code_index::projection::{ ChunkProjectionDecisionV1, CodeChunkProjectionSink, ProjectionReceiptBuilderV1, @@ -1173,6 +1173,20 @@ fn disk_artifact_resume_reopen_and_lexical_results_match_one_shot_projection() { .expect("artifact row lookup") .expect("artifact occurrence"); assert_eq!(occurrence.logical_path, "src/artifact.ts"); + let symbol_chunk = chunks + .iter() + .find(|chunk| chunk.chunk().anchor.symbol_occurrence_id.is_some()) + .expect("parser-backed symbol chunk"); + let symbol_occurrence = reader + .occurrence_by_chunk(&symbol_chunk.chunk().id) + .expect("artifact symbol row lookup") + .expect("artifact symbol occurrence"); + assert_eq!(symbol_occurrence.simple_name.as_deref(), Some("render")); + assert_eq!( + symbol_occurrence.qualified_name.as_deref(), + Some("src/artifact.ts::render") + ); + assert_eq!(symbol_occurrence.kind.as_deref(), Some("function")); let import_witness = reader .import_membership(&import_evidence) .expect("import membership") @@ -3846,9 +3860,21 @@ fn sealed_page_retained_bytes_include_digest_identities() { + evidence.local_name.as_ref().map_or(0, String::capacity) }, ); + let symbol_display_bytes = page.symbol_displays().iter().fold( + page.symbol_display_capacity() + .saturating_mul(std::mem::size_of::< + Option, + >()), + |bytes, display| { + bytes.saturating_add(display.as_ref().map_or( + 0, + VerifiedSealedLexicalSymbolDisplayV1::retained_owned_bytes, + )) + }, + ); assert_eq!( page.retained_owned_bytes(), - payload_bytes + 8 * sha256_digest_len, + payload_bytes + symbol_display_bytes + 8 * sha256_digest_len, "page {} retained bytes must include its eight digest identity strings", page.page_ordinal() ); From 6ba7b5ab5b059fe59bc2bad3728ff60f120cb0ce Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 21:21:05 +0000 Subject: [PATCH 65/69] perf(index): hydrate active text results from artifact --- src/daemon/code_index_executor.rs | 187 +++++++++++++++++++---- src/daemon/code_index_scheduler.rs | 27 +++- src/daemon/code_index_scheduler/tests.rs | 27 ++++ 3 files changed, 212 insertions(+), 29 deletions(-) diff --git a/src/daemon/code_index_executor.rs b/src/daemon/code_index_executor.rs index 05757ffb97..b9a9763aca 100644 --- a/src/daemon/code_index_executor.rs +++ b/src/daemon/code_index_executor.rs @@ -299,6 +299,140 @@ pub(super) fn code_index_search_display_binding( Ok((display, provenance)) } +pub(super) fn code_index_text_search_display_binding( + latest: &code_index_scheduler::LatestCodeTextGenerationV1, + request: &tracedecay_domain::RetrievalRequest, + candidate: &tracedecay_domain::RankedCandidate, +) -> std::result::Result< + ( + code_search::CodeIndexSearchDisplayV1, + tracedecay_domain::OccurrenceProvenance, + ), + tracedecay_query::retrieval::hydrate::HydrationUnavailableV1, +> { + use tracedecay_query::retrieval::hydrate::HydrationUnavailableV1; + + let metadata = latest.metadata(); + let manifest = metadata.manifest(); + let snapshot = metadata.snapshot(); + if request.scope.privacy_domain != manifest.privacy_domain + || request.scope.root.repository != snapshot.repository + || request.scope.root.worktree != snapshot.worktree + || request.scope.root.reference != snapshot.reference + || request.snapshot.freshness_digest.as_str() != manifest.snapshot_digest.as_str() + || request.snapshot.captured_at != manifest.seal.sealed_at + { + return Err(HydrationUnavailableV1::Stale); + } + + let source_prefix = format!("code-chunk:{}:", manifest.generation_id.as_str()); + let (provenance, chunk_id) = candidate + .candidate + .occurrences + .iter() + .find_map(|provenance| { + let chunk_id = provenance + .source_occurrence_id + .as_str() + .strip_prefix(&source_prefix)?; + (provenance.repository_id.as_ref() == Some(&request.scope.root.repository) + && provenance.source_namespace == provenance.freshness.source_namespace + && provenance.freshness.compatibility + == tracedecay_domain::FreshnessCompatibilityV1::Current + && provenance.source_namespace.as_str() == "ns.code.daemon") + .then_some((provenance.clone(), chunk_id)) + }) + .ok_or(HydrationUnavailableV1::Invalid)?; + let chunk_id = tracedecay_domain::CodeSearchChunkId::new(chunk_id.to_owned()) + .map_err(|_| HydrationUnavailableV1::Invalid)?; + let occurrence = latest + .artifact_occurrence_by_chunk(&chunk_id) + .map_err(|_| HydrationUnavailableV1::AuthorityUnavailable)?; + if occurrence.generation != manifest.generation_id + || !snapshot.files.iter().any(|file| { + file.file_occurrence_id == occurrence.file + && file.logical_path == occurrence.logical_path + && file.disposition == tracedecay_domain::SnapshotFileDispositionV1::Present + }) + { + return Err(HydrationUnavailableV1::Stale); + } + + let anchor = candidate.candidate.anchor_id.as_str(); + if let Some(symbol) = anchor.strip_prefix("code-symbol:") { + if occurrence.symbol.as_ref().map(|value| value.as_str()) != Some(symbol) { + return Err(HydrationUnavailableV1::Invalid); + } + } else if anchor.strip_prefix("code-chunk:") != Some(chunk_id.as_str()) { + return Err(HydrationUnavailableV1::Invalid); + } + + let display = match occurrence.symbol { + Some(_) => code_search::CodeIndexSearchDisplayV1 { + name: occurrence + .simple_name + .ok_or(HydrationUnavailableV1::Invalid)?, + qualified_name: occurrence + .qualified_name + .ok_or(HydrationUnavailableV1::Invalid)?, + kind: occurrence.kind.ok_or(HydrationUnavailableV1::Invalid)?, + path: occurrence.logical_path, + }, + None => { + if occurrence.simple_name.is_some() + || occurrence.qualified_name.is_some() + || occurrence.kind.is_some() + { + return Err(HydrationUnavailableV1::Invalid); + } + let name = occurrence + .logical_path + .rsplit('/') + .next() + .unwrap_or(occurrence.logical_path.as_str()) + .to_owned(); + code_search::CodeIndexSearchDisplayV1 { + name, + qualified_name: occurrence.logical_path.clone(), + kind: "file".to_owned(), + path: occurrence.logical_path, + } + } + }; + Ok((display, provenance)) +} + +enum CodeIndexSearchDisplaySourceV1 { + Text(code_index_scheduler::LatestCodeTextGenerationV1), + Complete { + latest: code_index_scheduler::LatestCompleteCodeIndexV1, + paths: CodeIndexDisplayPathIndexV1, + }, +} + +impl CodeIndexSearchDisplaySourceV1 { + fn binding( + &self, + request: &tracedecay_domain::RetrievalRequest, + candidate: &tracedecay_domain::RankedCandidate, + ) -> std::result::Result< + ( + code_search::CodeIndexSearchDisplayV1, + tracedecay_domain::OccurrenceProvenance, + ), + tracedecay_query::retrieval::hydrate::HydrationUnavailableV1, + > { + match self { + Self::Text(latest) => { + code_index_text_search_display_binding(latest, request, candidate) + } + Self::Complete { latest, paths } => { + code_index_search_display_binding(latest.generation(), paths, request, candidate) + } + } + } +} + fn code_index_symbol_display( symbol: &crate::code_index::lineage::LineageSymbolRecordV1, display_paths: &CodeIndexDisplayPathIndexV1, @@ -791,21 +925,28 @@ pub(super) fn code_index_search_executor( None, ), }; - let latest = match generation_for_hydration( - &schedulers, - &terminal_scope, - &executed.query.generation, - control.deadline.clone(), - control.cancellation.clone(), - ) - .await - { - Ok(latest) => latest, - Err(outcome) => return outcome, - }; - let display_paths = - match CodeIndexDisplayPathIndexV1::for_generation(latest.generation()) { - Ok(display_paths) => display_paths, + let display_source = if let Some(text) = schedulers + .latest_text_serving_for_scope(&terminal_scope) + .await + .filter(|text| { + text.metadata().manifest().generation_id == executed.query.generation + }) { + CodeIndexSearchDisplaySourceV1::Text(text) + } else { + let latest = match generation_for_hydration( + &schedulers, + &terminal_scope, + &executed.query.generation, + control.deadline.clone(), + control.cancellation.clone(), + ) + .await + { + Ok(latest) => latest, + Err(outcome) => return outcome, + }; + let paths = match CodeIndexDisplayPathIndexV1::for_generation(latest.generation()) { + Ok(paths) => paths, Err(_) => { return code_index_search_unavailable_for_generation( Some(executed.query.generation.as_str().to_owned()), @@ -814,6 +955,8 @@ pub(super) fn code_index_search_executor( ); } }; + CodeIndexSearchDisplaySourceV1::Complete { latest, paths } + }; let mut hydration_request = executed.query.sanitized.request().clone(); let hydration_budget = code_index_search_hydration_budget( accepted_semantic_budget, @@ -896,12 +1039,7 @@ pub(super) fn code_index_search_executor( _permit: &tracedecay_query::retrieval::hydrate::HydrationWorkPermitV1| { use tracedecay_query::retrieval::hydrate::HydrationPreflightOutcomeV1; - match code_index_search_display_binding( - latest.generation(), - &display_paths, - request, - candidate, - ) + match display_source.binding(request, candidate) .and_then(|(display, _)| code_index_search_display_bytes(&display)) { Ok(estimated_bytes) => { @@ -916,12 +1054,7 @@ pub(super) fn code_index_search_executor( _permit: &tracedecay_query::retrieval::hydrate::HydrationWorkPermitV1| { use tracedecay_query::retrieval::hydrate::HydrationReadOutcomeV1; - let (display, provenance) = match code_index_search_display_binding( - latest.generation(), - &display_paths, - request, - candidate, - ) { + let (display, provenance) = match display_source.binding(request, candidate) { Ok(binding) => binding, Err(reason) => return HydrationReadOutcomeV1::Unavailable(reason), }; diff --git a/src/daemon/code_index_scheduler.rs b/src/daemon/code_index_scheduler.rs index 38433bdd07..370828f855 100644 --- a/src/daemon/code_index_scheduler.rs +++ b/src/daemon/code_index_scheduler.rs @@ -84,8 +84,9 @@ use crate::{ CODE_LEXICAL_ARTIFACT_QUERY_CACHE_BUDGET_BYTES_V1, CodeExactLexicalArtifactReaderV1, CodeLexicalArtifactBuilderV1, CodeLexicalArtifactErrorV1, CodeLexicalArtifactFinalizationPhaseV1, CodeLexicalArtifactFinalizationStepV1, - CodeLexicalArtifactReaderV1, CodeLexicalProjectionMetadataV1, LexicalLane, - LexicalLaneEvidence, LexicalLaneRequest, LexicalLaneRetriever, + CodeLexicalArtifactOccurrenceV1, CodeLexicalArtifactReaderV1, + CodeLexicalProjectionMetadataV1, LexicalLane, LexicalLaneEvidence, LexicalLaneRequest, + LexicalLaneRetriever, }, ports::RetrievalPortError, }, @@ -2039,6 +2040,20 @@ impl ProductionCodeIndexQueryOwnersV1 { .ok_or(tracedecay_query::retrieval::QueryExecutionContractErrorV1::RecordUnavailable) } + fn artifact_occurrence_by_chunk( + &self, + chunk: &tracedecay_domain::CodeSearchChunkId, + ) -> Result { + self.hydration + .occurrence_by_chunk(chunk) + .map_err(|error| RetrievalPortError::AuthorityUnavailable(error.to_string()))? + .ok_or_else(|| { + RetrievalPortError::AuthorityUnavailable( + "lexical artifact row is unavailable".to_owned(), + ) + }) + } + pub fn retrieve_lexical( &self, request: &LexicalLaneRequest<'_>, @@ -2583,6 +2598,14 @@ impl LatestCodeTextGenerationV1 { &self.metadata } + pub(in crate::daemon) fn artifact_occurrence_by_chunk( + &self, + chunk: &tracedecay_domain::CodeSearchChunkId, + ) -> Result { + self.production_query_owners_with_budget(&queries::maximum_retrieval_budget())? + .artifact_occurrence_by_chunk(chunk) + } + #[cfg(test)] fn activate_text_serving(&self) -> Result<(), RetrievalPortError> { if !self.advance_text_serving(TEXT_ARTIFACT_MAXIMUM_WORK_PER_ADVANCE_V1)? { diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 6c87ab2034..b50cd3d61f 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -10197,6 +10197,33 @@ async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() !current.served_stale, "a reconciled graph-off text owner must report current exact and lexical coverage" ); + let text = registry + .latest_text_serving_for_scope(&scope) + .await + .expect("ready graph-off text owner"); + let candidate = current + .authorized + .fallback + .ordered_candidates + .first() + .expect("artifact-backed ranked candidate"); + let (display, _) = crate::daemon::code_index_executor::code_index_text_search_display_binding( + &text, + current.sanitized.request(), + candidate, + ) + .expect("artifact-backed result display"); + assert_eq!(display.name, "alpha_0000"); + assert_eq!(display.kind, "function"); + assert_eq!(display.path, "src/file_0000.rs"); + assert_eq!( + scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .sealed_decode_count(), + 0, + "artifact-backed display hydration must not decode the sealed generation" + ); let query_scope = CodeQueryScope::new(executed.generation.clone(), None).expect("query scope"); let exact_operation = From 30128f3cd0a8e0be5d8e56d215cda65792352490 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 22:02:57 +0000 Subject: [PATCH 66/69] perf(semantic): abstain before generation decode --- .../semantic_query_runtime.rs | 13 ++++ src/daemon/code_index_scheduler/tests.rs | 59 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/daemon/code_index_scheduler/semantic_query_runtime.rs b/src/daemon/code_index_scheduler/semantic_query_runtime.rs index 953cf28e22..35eeddc3f2 100644 --- a/src/daemon/code_index_scheduler/semantic_query_runtime.rs +++ b/src/daemon/code_index_scheduler/semantic_query_runtime.rs @@ -358,6 +358,19 @@ impl CodeIndexSchedulerRegistryV1 { let query = self .execute_controlled_query(scope, input, control.clone()) .await?; + if self + .semantic_query_authority_for_scope(scope) + .await + .is_none() + { + let semantic = semantic_abstention( + mode, + SemanticAbstentionV1::CalibrationUnavailable, + Arc::clone(&query.authorized.fallback), + ) + .map_err(|error| bind_semantic_execution_error(&query.generation, error))?; + return Ok(ExecutedQuerySemanticSearchV1 { query, semantic }); + } let latest = match self.generation_for(scope, &query.generation).await { Ok(Some(latest)) => latest, Ok(None) => { diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index b50cd3d61f..3bce933451 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -70,6 +70,9 @@ use tracedecay_query::retrieval::rerank::{ BoundedRerankRuntimeV1, DeterministicLocalRerankExecutorV1, LocalRerankFailureV1, LocalRerankInputV1, LocalRerankPermitV1, RerankExecutionControlV1, }; +use tracedecay_query::retrieval::semantic::{ + SemanticAbstentionV1, SemanticExecutionControl, SemanticQueryModeV1, +}; use tracedecay_runtime_core::resident_memory::{ DEFAULT_PROCESS_RESIDENT_MEMORY_LIMIT_V1, ProcessResidentMemoryV1, }; @@ -1056,6 +1059,18 @@ impl RerankExecutionControlV1 for ReadyRerankControlV1 { } } +struct ReadySemanticControlV1; + +impl SemanticExecutionControl for ReadySemanticControlV1 { + fn is_cancelled(&self) -> bool { + false + } + + fn elapsed_micros(&self) -> u64 { + 0 + } +} + fn application_context( operation: &tracedecay_application::ApplicationOperation, repository: RepositoryId, @@ -10197,6 +10212,50 @@ async fn graph_off_overflow_preserves_text_owner_progress_without_full_decode() !current.served_stale, "a reconciled graph-off text owner must report current exact and lexical coverage" ); + let semantic = registry + .execute_query_with_semantic( + fixture.path(), + &scope, + core_search_request("alpha_0000"), + Arc::new(ReadySemanticControlV1), + SemanticQueryModeV1::FallbackAllowed, + ) + .await + .expect("graph-off fallback semantic query"); + assert_eq!(semantic.query.generation, current.generation); + assert!(matches!( + semantic.semantic, + super::semantic_query_runtime::SemanticAugmentationOutcomeV1::Fallback { + abstention: SemanticAbstentionV1::CalibrationUnavailable, + .. + } + )); + let strict = registry + .execute_query_with_semantic( + fixture.path(), + &scope, + core_search_request("alpha_0000"), + Arc::new(ReadySemanticControlV1), + SemanticQueryModeV1::StrictSemantic, + ) + .await; + assert!(matches!( + strict, + Err( + super::semantic_query_runtime::QuerySemanticSearchExecutionErrorV1::StrictSemanticUnavailable { + generation, + abstention: SemanticAbstentionV1::CalibrationUnavailable, + } + ) if generation == current.generation + )); + assert_eq!( + scheduler + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .sealed_decode_count(), + 0, + "semantic fallback without an activated profile must not decode the sealed generation" + ); let text = registry .latest_text_serving_for_scope(&scope) .await From fcc14f283dc4c99393914e625e54ec6a35043206 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 22:35:13 +0000 Subject: [PATCH 67/69] fix(index): decouple reconcile telemetry persistence --- .../tracedecay-usecases/src/observability.rs | 2 +- .../src/observability/emit.rs | 16 +++ .../code_index_scheduler/observability.rs | 37 +++---- src/daemon/code_index_scheduler/registry.rs | 8 +- src/daemon/code_index_scheduler/tests.rs | 101 +++++++++++++++++- src/daemon/invocation_state.rs | 6 +- .../invocation/observability_producer.rs | 17 --- 7 files changed, 141 insertions(+), 46 deletions(-) diff --git a/crates/tracedecay-usecases/src/observability.rs b/crates/tracedecay-usecases/src/observability.rs index 892f981f90..bf42a1b4e1 100644 --- a/crates/tracedecay-usecases/src/observability.rs +++ b/crates/tracedecay-usecases/src/observability.rs @@ -38,7 +38,7 @@ pub use delivery_recorder::{ }; pub use delivery_settlement::{DeliverySettlementAuthorityV1, DeliverySettlementEmissionV1}; pub use emit::{ - record_adoption_eligibility, record_adoption_outcome, record_index, record_latency, + emit_index, record_adoption_eligibility, record_adoption_outcome, record_index, record_latency, record_operation_resource, record_retrieval_query, record_storage, }; pub use execution_emit::{ diff --git a/crates/tracedecay-usecases/src/observability/emit.rs b/crates/tracedecay-usecases/src/observability/emit.rs index c07cafae40..3130f3c3e1 100644 --- a/crates/tracedecay-usecases/src/observability/emit.rs +++ b/crates/tracedecay-usecases/src/observability/emit.rs @@ -36,6 +36,8 @@ use tracedecay_global_db::RegisteredGlobalDb; use crate::event_lane::record_observability; +use super::{BoundedObservabilityProducerV1, ObservabilityEmissionOutcomeV1}; + const SCHEMA_REVISION: u32 = 1; const CONFIGURATION_REVISION: &str = "registered-project-session.v1"; @@ -617,6 +619,20 @@ pub async fn record_index( record_observability(db, envelope).await } +/// Offers one code-index generation lifecycle observation to the mounted +/// bounded producer without waiting for project-store persistence. +pub fn emit_index( + producer: &BoundedObservabilityProducerV1, + observation: IndexObservedV1, +) -> Result { + let envelope = index_envelope( + &producer.identity().authorized_scope_ref, + now_micros().0, + observation, + )?; + producer.try_emit(envelope) +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/src/daemon/code_index_scheduler/observability.rs b/src/daemon/code_index_scheduler/observability.rs index edeff5d7b8..660c872201 100644 --- a/src/daemon/code_index_scheduler/observability.rs +++ b/src/daemon/code_index_scheduler/observability.rs @@ -11,49 +11,50 @@ use tracedecay_domain::{ use tracedecay_query::retrieval::AuthorizedQueryFallbackV1; use tracedecay_query::retrieval::observation::observe_composition; use tracedecay_usecases::observability::{ - BoundedObservabilityProducerV1, emit_retrieval_pipeline, record_index, + BoundedObservabilityProducerV1, ObservabilityEmissionOutcomeV1, emit_index, + emit_retrieval_pipeline, }; use super::CodeIndexReconcileOutcomeV1; /// Project-bound observation authority installed once per mounted worktree /// (`CodeIndexSchedulerRegistryV1::install_index_observability`). The session -/// database carries index lifecycle receipts directly; the bounded producer -/// carries the retrieval-pipeline families off the query hot path. +/// bounded producer carries lifecycle and retrieval-pipeline observations off +/// the scheduler and query hot paths. #[derive(Clone)] pub(in crate::daemon) struct CodeIndexObservabilityV1 { - session_db: crate::global_db::RegisteredGlobalDbLeaseV1, producer: Arc, } impl CodeIndexObservabilityV1 { - pub(in crate::daemon) fn new( - session_db: crate::global_db::RegisteredGlobalDbLeaseV1, - producer: Arc, - ) -> Self { - Self { - session_db, - producer, - } + pub(in crate::daemon) fn new(producer: Arc) -> Self { + Self { producer } } /// Records one terminal reconcile pass as a canonical index lifecycle /// observation beside the worker's in-memory cadence receipt. - pub(in crate::daemon) async fn record_reconcile_outcome( + pub(in crate::daemon) fn record_reconcile_outcome( &self, outcome: &CodeIndexReconcileOutcomeV1, service_micros: u64, queue_depth_bucket: QueueDepthBucketV1, ) { let observation = reconcile_index_observation(outcome, service_micros, queue_depth_bucket); - if let Err(error) = record_index(self.session_db.as_ref(), observation).await { - tracing::debug!( + match emit_index(self.producer.as_ref(), observation) { + Ok(ObservabilityEmissionOutcomeV1::Enqueued) => {} + Ok(ObservabilityEmissionOutcomeV1::DroppedAtCapacity) => tracing::debug!( + event = "code_index_observability", + family = "index", + outcome = "dropped_at_capacity", + "code-index lifecycle observation was refused by the bounded producer" + ), + Err(error) => tracing::debug!( event = "code_index_observability", family = "index", outcome = "unavailable", - error = ?error, - "code-index lifecycle observation could not be recorded" - ); + error, + "code-index lifecycle observation could not be enqueued" + ), } } diff --git a/src/daemon/code_index_scheduler/registry.rs b/src/daemon/code_index_scheduler/registry.rs index 1cce7c4195..43b49847e6 100644 --- a/src/daemon/code_index_scheduler/registry.rs +++ b/src/daemon/code_index_scheduler/registry.rs @@ -2850,9 +2850,11 @@ impl CodeIndexSchedulerRegistryV1 { tracedecay_domain::QueueDepthBucketV1::OneToEight } }; - observability - .record_reconcile_outcome(outcome, service_micros, queue_depth_bucket) - .await; + observability.record_reconcile_outcome( + outcome, + service_micros, + queue_depth_bucket, + ); } } else { // Surface bounded non-terminal failure without new project-path data. diff --git a/src/daemon/code_index_scheduler/tests.rs b/src/daemon/code_index_scheduler/tests.rs index 3bce933451..0a4e6506b4 100644 --- a/src/daemon/code_index_scheduler/tests.rs +++ b/src/daemon/code_index_scheduler/tests.rs @@ -11192,6 +11192,86 @@ async fn wait_for_event_to_ready( } } +/// A successful reconcile is product state; optional lifecycle telemetry may +/// not keep its in-progress guard or freshness response open while the +/// observability store is busy. +#[tokio::test] +async fn blocked_observability_store_does_not_hold_reconcile_readiness() { + let _pin = crate::config::PinnedUserDataDir::new(); + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn alpha() -> u32 { 1 }\n")]); + let store = TempDir::new().expect("store root"); + let (registry, scope) = mounted_core_query_worktree(&fixture, &store).await; + let runtime = tracedecay_global_db::tests::harness::RegisteredGlobalDbTestRuntime::project( + tracedecay_runtime_core::storage::default_profile_root().expect("profile root"), + fixture.path(), + scope.project_id.clone(), + ) + .await + .expect("registered runtime"); + let database = runtime.project_database_arc().expect("project database"); + let producer = Arc::new( + tracedecay_usecases::observability::BoundedObservabilityProducerV1::start( + database.clone(), + tracedecay_usecases::observability::ObservabilityProducerIdentityV1 { + authorized_scope_ref: scope.project_id.as_str().to_owned(), + process_boot_id: "boot:code-index-readiness".to_owned(), + producer_revision: "code-index-readiness-test.v1".to_owned(), + configuration_revision: "code-index-readiness-config.v1".to_owned(), + policy_revision: "code-index-readiness-policy.v1".to_owned(), + }, + 8, + ) + .expect("bounded producer"), + ); + registry + .install_index_observability( + fixture.path(), + super::observability::CodeIndexObservabilityV1::new(Arc::clone(&producer)), + ) + .await + .expect("install observability lane"); + + let blocked_writer = database + .begin_write_transaction() + .await + .expect("hold observability writer"); + let initial = registry + .latest_generation_id(fixture.path()) + .await + .expect("initial generation"); + fixture.edit("src/lib.rs", "pub fn alpha() -> u32 { 2 }\n"); + assert!( + registry + .notify_path(fixture.path(), fixture.path().join("src/lib.rs")) + .await + ); + let _ = wait_for_generation_change(®istry, fixture.path(), &initial).await; + + let deadline = std::time::Instant::now() + Duration::from_secs(1); + loop { + let freshness = registry + .dashboard_freshness(fixture.path()) + .await + .expect("dashboard freshness"); + if freshness.staleness_state.as_deref() == Some("fresh") && freshness.coverage == "complete" + { + break; + } + assert!( + std::time::Instant::now() <= deadline, + "optional telemetry held successful reconcile readiness: {freshness:?}" + ); + tokio::task::yield_now().await; + } + + blocked_writer + .commit() + .await + .expect("release observability writer"); + registry.shutdown().await; + producer.shutdown().await.expect("flush producer"); +} + /// The installed observability lane must persist one canonical index /// lifecycle observation when a reconcile publishes a generation, and the /// retrieval-pipeline families when a query composition completes, all in the @@ -11228,10 +11308,7 @@ async fn installed_observability_lane_records_index_and_retrieval_observations() registry .install_index_observability( fixture.path(), - super::observability::CodeIndexObservabilityV1::new( - database.clone(), - Arc::clone(&producer), - ), + super::observability::CodeIndexObservabilityV1::new(Arc::clone(&producer)), ) .await .expect("install observability lane"); @@ -11250,6 +11327,22 @@ async fn installed_observability_lane_records_index_and_retrieval_observations() ); let _ = wait_for_generation_change(®istry, fixture.path(), &initial).await; + let ready_deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if registry + .latest_text_serving_for_scope(&scope) + .await + .is_some() + { + break; + } + assert!( + std::time::Instant::now() <= ready_deadline, + "published text generation did not become queryable" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + // One real query composition through the mounted authority carries the // retrieval-pipeline families through the bounded producer. let _executed = registry diff --git a/src/daemon/invocation_state.rs b/src/daemon/invocation_state.rs index 6cccdc301e..fb28f63003 100644 --- a/src/daemon/invocation_state.rs +++ b/src/daemon/invocation_state.rs @@ -469,16 +469,16 @@ impl DaemonInvocationState { // observability lane uninstalled and nothing records. match self .service - .observability_producer_with_database(Some(&canonical_project_root)) + .observability_producer(Some(&canonical_project_root)) .await { - Some((session_db, producer)) => { + Some(producer) => { if let Err(error) = self .code_index_schedulers .install_index_observability( &canonical_project_root, code_index_scheduler::observability::CodeIndexObservabilityV1::new( - session_db, producer, + producer, ), ) .await diff --git a/src/daemon/service/invocation/observability_producer.rs b/src/daemon/service/invocation/observability_producer.rs index 415573655e..6f03d6af91 100644 --- a/src/daemon/service/invocation/observability_producer.rs +++ b/src/daemon/service/invocation/observability_producer.rs @@ -158,23 +158,6 @@ impl DaemonInvocationService { .await } - /// The mounted producer together with the exact project session database - /// it writes through, for owners that also record directly through the - /// registered observation authority. - pub(crate) async fn observability_producer_with_database( - &self, - project_root: Option<&Path>, - ) -> Option<( - crate::global_db::RegisteredGlobalDbLeaseV1, - Arc, - )> { - self.project_runtimes - .read::(project_root?, |registered| { - (registered.database(), registered.producer()) - }) - .await - } - pub(crate) fn observability_producer_for_project_root( &self, project_root: &Path, From 8601dc2729a880cf4d8690f1a330e7f15ab4d2c5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 23:18:28 +0000 Subject: [PATCH 68/69] fix(git): resolve human PR branch refs --- src/mcp/tools/definitions/git.rs | 4 +- src/mcp/tools/handlers/git/shell.rs | 219 ++++++++++++++++++++++++++-- 2 files changed, 209 insertions(+), 14 deletions(-) diff --git a/src/mcp/tools/definitions/git.rs b/src/mcp/tools/definitions/git.rs index 6e9d0ebeb0..656662a34c 100644 --- a/src/mcp/tools/definitions/git.rs +++ b/src/mcp/tools/definitions/git.rs @@ -104,11 +104,11 @@ pub(super) fn def_pr_context() -> ToolDefinition { "properties": { "base_ref": { "type": "string", - "description": "Base branch or ref to compare against (default: detected repository default branch)" + "description": "Base branch or ref to compare against (default: detected repository default branch). A short branch name selects the descendant of its local and origin tracking tips; use an explicit ref when they diverge." }, "head_ref": { "type": "string", - "description": "Head branch or ref (default: 'HEAD')" + "description": "Head branch or ref (default: 'HEAD'). Accepts local branches, remote-tracking refs such as origin/topic, full refs, and Git revision expressions." }, "maximum_symbols": { "type": "integer", diff --git a/src/mcp/tools/handlers/git/shell.rs b/src/mcp/tools/handlers/git/shell.rs index 96e03695ac..929bcc7476 100644 --- a/src/mcp/tools/handlers/git/shell.rs +++ b/src/mcp/tools/handlers/git/shell.rs @@ -5,6 +5,72 @@ use super::*; const PR_CONTEXT_MAX_ANCESTRY_COMMITS: usize = 100_000; const PR_CONTEXT_MAX_CHANGED_FILES: usize = 20_000; +fn resolve_pr_comparison_commit( + repo: &gix::Repository, + requested: &str, +) -> std::result::Result { + if requested == "HEAD" { + return repo + .rev_parse_single(requested) + .map_err(|error| format!("cannot resolve '{requested}': {error}"))? + .object() + .map_err(|error| format!("cannot read object for '{requested}': {error}"))? + .peel_to_commit() + .map(|commit| commit.id) + .map_err(|error| format!("cannot peel '{requested}' to commit: {error}")); + } + let local = exact_reference_commit(repo, &format!("refs/heads/{requested}"), requested)?; + let remote = + exact_reference_commit(repo, &format!("refs/remotes/origin/{requested}"), requested)?; + match (local, remote) { + (Some(local), Some(remote)) if local != remote => { + let merge_base = repo.merge_base(local, remote).map_err(|error| { + format!("cannot compare local and remote tips for '{requested}': {error}") + })?; + if merge_base == local { + Ok(remote) + } else if merge_base == remote { + Ok(local) + } else { + Err(format!( + "branch '{requested}' has diverged local and origin tips; pass 'refs/heads/{requested}' or 'origin/{requested}' explicitly" + )) + } + } + (Some(local), _) => Ok(local), + (_, Some(remote)) => Ok(remote), + (None, None) => repo + .rev_parse_single(requested) + .map_err(|error| format!("cannot resolve '{requested}': {error}"))? + .object() + .map_err(|error| format!("cannot read object for '{requested}': {error}"))? + .peel_to_commit() + .map(|commit| commit.id) + .map_err(|error| format!("cannot peel '{requested}' to commit: {error}")), + } +} + +fn exact_reference_commit( + repo: &gix::Repository, + full_name: &str, + requested: &str, +) -> std::result::Result, String> { + let Ok(full_name) = gix::refs::FullName::try_from(full_name) else { + return Ok(None); + }; + let reference = repo + .try_find_reference(&full_name) + .map_err(|error| format!("cannot inspect '{requested}': {error}"))?; + reference + .map(|mut reference| { + reference + .peel_to_commit() + .map(|commit| commit.id) + .map_err(|error| format!("cannot peel '{requested}' to commit: {error}")) + }) + .transpose() +} + /// Diff two git refs and return changed file paths with coarse status. pub(super) fn git_diff_file_changes( project_root: &std::path::Path, @@ -37,20 +103,12 @@ pub(super) fn git_pr_comparison_controlled( let repo = gix::open(project_root).map_err(|e| format!("failed to open git repo: {e}"))?; check_git_pr_cancelled(cancelled)?; let base_commit = repo - .rev_parse_single(base_ref) - .map_err(|e| format!("cannot resolve '{base_ref}': {e}"))? - .object() - .map_err(|e| format!("cannot read object for '{base_ref}': {e}"))? - .peel_to_commit() - .map_err(|e| format!("cannot peel '{base_ref}' to commit: {e}"))?; + .find_commit(resolve_pr_comparison_commit(&repo, base_ref)?) + .map_err(|error| format!("cannot read commit for '{base_ref}': {error}"))?; check_git_pr_cancelled(cancelled)?; let head_commit = repo - .rev_parse_single(head_ref) - .map_err(|e| format!("cannot resolve '{head_ref}': {e}"))? - .object() - .map_err(|e| format!("cannot read object for '{head_ref}': {e}"))? - .peel_to_commit() - .map_err(|e| format!("cannot peel '{head_ref}' to commit: {e}"))?; + .find_commit(resolve_pr_comparison_commit(&repo, head_ref)?) + .map_err(|error| format!("cannot read commit for '{head_ref}': {error}"))?; let base_oid = base_commit.id.to_string(); let head_oid = head_commit.id.to_string(); check_git_pr_cancelled(cancelled)?; @@ -485,6 +543,143 @@ mod tests { assert_eq!(comparison.commits[0]["subject"], "feature"); } + #[test] + fn pr_comparison_resolves_a_remote_only_branch_by_human_name() { + let temp = tempfile::tempdir().expect("temp repo"); + let root = temp.path(); + test_git(root, &["init", "-b", "main"]); + std::fs::write(root.join("base.txt"), "base\n").expect("write base"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "base"]); + test_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + test_git(root, &["switch", "--detach", "HEAD"]); + test_git(root, &["branch", "-D", "main"]); + test_git(root, &["switch", "-c", "feature"]); + std::fs::write(root.join("feature.txt"), "feature\n").expect("write feature"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "feature"]); + + let comparison = git_pr_comparison(root, "main", "feature") + .expect("a human branch name resolves its remote-tracking tip"); + let paths = comparison + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!(paths, ["feature.txt"]); + + let explicit = git_pr_comparison(root, "origin/main", "feature") + .expect("an explicit remote-tracking ref resolves without an object id"); + let explicit_paths = explicit + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!(explicit_paths, ["feature.txt"]); + } + + #[test] + fn pr_comparison_prefers_the_descendant_remote_tip_for_a_human_branch_name() { + let temp = tempfile::tempdir().expect("temp repo"); + let root = temp.path(); + test_git(root, &["init", "-b", "main"]); + std::fs::write(root.join("base.txt"), "base\n").expect("write base"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "base"]); + let local_main = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output() + .expect("read local main"); + assert!(local_main.status.success()); + let local_main = String::from_utf8(local_main.stdout) + .expect("UTF-8 oid") + .trim() + .to_owned(); + + std::fs::write(root.join("remote.txt"), "remote advance\n").expect("write remote file"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "remote advance"]); + test_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + test_git(root, &["reset", "--hard", &local_main]); + test_git( + root, + &["switch", "-c", "feature", "refs/remotes/origin/main"], + ); + std::fs::write(root.join("feature.txt"), "feature\n").expect("write feature"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "feature"]); + + let comparison = git_pr_comparison(root, "main", "feature") + .expect("a human branch name selects the newer remote-tracking tip"); + let paths = comparison + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!(paths, ["feature.txt"]); + } + + #[test] + fn pr_comparison_refuses_to_guess_between_diverged_local_and_remote_tips() { + let temp = tempfile::tempdir().expect("temp repo"); + let root = temp.path(); + test_git(root, &["init", "-b", "main"]); + std::fs::write(root.join("base.txt"), "base\n").expect("write base"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "base"]); + test_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + std::fs::write(root.join("local.txt"), "local\n").expect("write local file"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "local advance"]); + test_git(root, &["switch", "--detach", "refs/remotes/origin/main"]); + std::fs::write(root.join("remote.txt"), "remote\n").expect("write remote file"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "remote advance"]); + test_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + test_git(root, &["switch", "main"]); + + let Err(error) = git_pr_comparison(root, "main", "HEAD") else { + panic!("diverged human branch names must require an explicit ref"); + }; + assert!( + error.contains("has diverged local and origin tips"), + "typed ambiguity names both competing authorities: {error}" + ); + } + + #[test] + fn pr_comparison_default_head_never_aliases_the_remote_default_branch() { + let temp = tempfile::tempdir().expect("temp repo"); + let root = temp.path(); + test_git(root, &["init", "-b", "main"]); + std::fs::write(root.join("base.txt"), "base\n").expect("write base"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "base"]); + test_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + test_git( + root, + &[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + ], + ); + test_git(root, &["switch", "-c", "feature"]); + std::fs::write(root.join("feature.txt"), "feature\n").expect("write feature"); + test_git(root, &["add", "."]); + test_git(root, &["commit", "-m", "feature"]); + + let comparison = git_pr_comparison(root, "main", "HEAD") + .expect("HEAD means the current checkout, never origin/HEAD"); + let paths = comparison + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!(paths, ["feature.txt"]); + } + #[test] fn pr_comparison_stops_from_inside_the_tree_diff_callback() { use std::sync::atomic::{AtomicUsize, Ordering}; From e09665ae2a300df05df0d779aaaa8c7c876d210c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 25 Aug 2026 23:55:09 +0000 Subject: [PATCH 69/69] fix(daemon): restore the evaluated semantic profile restart wait `fix(semantic): build activation candidate in daemon` removed `wait_for_evaluated_semantic_profile_current` from this module but left its only call site in `advanced_workflow_journey_test.rs` untouched, so the auto-discovered `daemon_suite` target no longer compiles: error[E0425]: cannot find function `wait_for_evaluated_semantic_profile_current` in module `task_session` The helper is restored verbatim from the last revision that carried it, which also puts the module's now-unused `ApplicationConfigurationGet` import back to work. It proves the evaluated profile is selected through the public configuration authority and ready through the mounted runtime authority, and that the readiness receipt authorizes the exact configuration the SDK selected. --- .../advanced_workflow_journey/task_session.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/daemon_suite/advanced_workflow_journey/task_session.rs b/tests/daemon_suite/advanced_workflow_journey/task_session.rs index 219e0fc246..f1cf7964b8 100644 --- a/tests/daemon_suite/advanced_workflow_journey/task_session.rs +++ b/tests/daemon_suite/advanced_workflow_journey/task_session.rs @@ -582,6 +582,67 @@ fn read_active_code_generation( .ok() } +/// Proves the evaluated profile is both selected through the public configuration +/// authority and ready through the mounted runtime authority before TaskSession +/// selection. TaskSession anchors deliberately retain only TaskSession provenance. +pub(super) fn wait_for_evaluated_semantic_profile_current( + home: &Path, + project: &Path, + client: &Client, +) { + let configured = client + .execute::( + &tracedecay_application::configuration::ConfigurationGetRequestV1 { + key: SettingKey::new(SEMANTIC_RUNTIME_SETTING_KEY) + .expect("semantic runtime setting key"), + }, + ) + .expect("read activated semantic runtime configuration through typed SDK") + .result; + let ConfigurationValueV1::Text(value) = &configured.effective_value else { + panic!( + "activated semantic runtime must retain its typed configuration text: {configured:?}" + ); + }; + let semantic_config: SemanticConfig = serde_json::from_str(value) + .unwrap_or_else(|error| panic!("decode activated semantic runtime configuration: {error}")); + let active_profile = semantic_config + .active_profile + .as_ref() + .expect("evaluated semantic profile must remain active through the typed SDK"); + assert_eq!( + active_profile.profile_id, EVALUATED_PROFILE_ID, + "the evaluated semantic profile selected through the typed SDK must remain active" + ); + + let deadline = Instant::now() + Duration::from_secs(120); + loop { + let status = semantic_runtime_status(home, project) + .unwrap_or_else(|| panic!("runtime returned an invalid semantic status")); + if let SemanticRuntimeStateV1::Current { receipt } = &status.state { + let runtime_configuration = status + .configuration + .as_ref() + .expect("ready semantic runtime must report its activation configuration"); + assert_eq!( + runtime_configuration.effective_behavior_digest, + configured.effective_behavior_digest, + "the runtime readiness receipt must authorize the exact evaluated configuration selected through the SDK" + ); + assert_eq!( + receipt.configuration, *runtime_configuration, + "the ready semantic receipt must retain the runtime's exact activation configuration" + ); + return; + } + assert!( + Instant::now() < deadline, + "timed out waiting for activated semantic runtime: {status:?}" + ); + std::thread::sleep(Duration::from_millis(250)); + } +} + fn semantic_runtime_status(home: &Path, project: &Path) -> Option { let value = serve_tool_call( home,