Skip to main content

relay_knowledge/code/index/plan/
mod.rs

1//! Plans bounded index batches, row budgets, and workspace metadata.
2
3use std::{
4    collections::{BTreeMap, VecDeque},
5    path::PathBuf,
6    sync::atomic::{AtomicUsize, Ordering},
7    thread,
8};
9
10use crate::domain::{
11    CodeIndexBatch, CodeIndexCheckpoint, CodeIndexResourceBudget, CodeIndexSession,
12    CodeMonorepoWorkspace, CodeRepositoryRegistration, CodeRepositorySelector,
13    CodeWorkspaceDetectionConfig, code_query_index_repair, code_query_index_subphase,
14    code_reference_resolution, code_reference_resolution_query_index_repair,
15    code_reference_search_query_index_repair, code_reference_search_rebuild,
16};
17
18use super::{
19    CodeIndexError,
20    changes::GitTreeEntry,
21    identity, parse_indexed_file,
22    scope::scoped_source_snapshot,
23    snapshot::{SnapshotBuild, SnapshotScopeFilters, detect_workspaces_for_source_snapshot},
24    source::{
25        RepositorySourceKind, ensure_filesystem_blobs_match_content_hashes,
26        ensure_filesystem_paths_match_content_hashes, filesystem_content_hashes_for_paths,
27        filesystem_tree_hash_from_path_hashes, source_snapshot_batch_bytes,
28    },
29};
30
31const GIT_BLOB_FETCH_GROUP: usize = CodeIndexResourceBudget::DEFAULT_MAX_FILES_PER_BATCH;
32const MIN_PARALLEL_PARSE_FILES: usize = 12;
33const MIN_PARALLEL_PARSE_BYTES: usize = 256 * 1024;
34const TARGET_PARSE_FILES_PER_WORKER: usize = 16;
35const TARGET_PARSE_BYTES_PER_WORKER: usize = 512 * 1024;
36
37#[derive(Debug, Clone)]
38struct PendingParsedFile {
39    parsed_byte_count: usize,
40    build: SnapshotBuild,
41}
42
43/// Blocking plan for a checkpointed full repository index.
44#[derive(Debug, Clone)]
45pub struct CodeIndexPlan {
46    registration: CodeRepositoryRegistration,
47    root: PathBuf,
48    commit: String,
49    tree_hash: String,
50    source_scope: String,
51    path_filters: Vec<String>,
52    language_filters: Vec<String>,
53    source_kind: RepositorySourceKind,
54    filesystem_path_hashes: BTreeMap<String, String>,
55    paths: Vec<GitTreeEntry>,
56    workspaces: Vec<CodeMonorepoWorkspace>,
57    cursor: usize,
58    parsed_overflow: VecDeque<PendingParsedFile>,
59    next_batch_index: usize,
60    resource_budget: CodeIndexResourceBudget,
61}
62
63#[derive(Debug)]
64pub(crate) enum CodeIndexPlanRecovery {
65    Resume(CodeIndexPlan),
66    ContentEquivalentRestart(CodeIndexPlan),
67}
68
69impl CodeIndexPlan {
70    /// Returns the durable session metadata that storage checkpoints.
71    pub fn session(&self) -> CodeIndexSession {
72        CodeIndexSession {
73            repository_id: self.registration.repository_id.clone(),
74            source_scope: self.source_scope.clone(),
75            base_resolved_commit_sha: None,
76            resolved_commit_sha: self.commit.clone(),
77            tree_hash: self.tree_hash.clone(),
78            path_filters: self.path_filters.clone(),
79            language_filters: self.language_filters.clone(),
80            full_replace: true,
81            total_path_count: self.paths.len(),
82            changed_path_count: self.paths.len(),
83            skipped_unchanged_count: 0,
84            deleted_paths: Vec::new(),
85            changed_paths: Vec::new(),
86            tombstones: Vec::new(),
87            workspaces: self.workspaces.clone(),
88            resource_budget: self.resource_budget,
89        }
90    }
91
92    /// Restores the parser cursor from a fully committed durable checkpoint.
93    /// Fresh plans still begin at zero; this path is only valid before any
94    /// in-memory parse work has started.
95    pub fn resume_from_checkpoint(
96        self,
97        checkpoint: &CodeIndexCheckpoint,
98    ) -> Result<Self, CodeIndexError> {
99        match self.recover_from_checkpoint(checkpoint)? {
100            CodeIndexPlanRecovery::Resume(plan) => Ok(plan),
101            CodeIndexPlanRecovery::ContentEquivalentRestart(_) => Err(invalid_checkpoint(
102                "a completed content-equivalent checkpoint with a different resolved commit must restart instead of resuming its cursor",
103            )),
104        }
105    }
106
107    pub(crate) fn recover_from_checkpoint(
108        mut self,
109        checkpoint: &CodeIndexCheckpoint,
110    ) -> Result<CodeIndexPlanRecovery, CodeIndexError> {
111        self.validate_pristine_resume_target()?;
112        self.validate_checkpoint_content_identity(checkpoint)?;
113        self.validate_checkpoint_progress(checkpoint)?;
114
115        if checkpoint.resolved_commit_sha != self.commit {
116            if checkpoint.state == "completed" {
117                return Ok(CodeIndexPlanRecovery::ContentEquivalentRestart(self));
118            }
119            return Err(invalid_checkpoint(
120                "resolved commit does not match the plan and only a completed content-equivalent checkpoint may restart",
121            ));
122        }
123
124        self.cursor = checkpoint.committed_file_count;
125        self.next_batch_index = checkpoint.batch_count.checked_add(1).ok_or_else(|| {
126            invalid_checkpoint("batch count cannot advance to the next batch index")
127        })?;
128
129        Ok(CodeIndexPlanRecovery::Resume(self))
130    }
131
132    pub(crate) fn resume_from_content_equivalent_restart_checkpoint(
133        self,
134        checkpoint: &CodeIndexCheckpoint,
135    ) -> Result<Self, CodeIndexError> {
136        if checkpoint.state != "indexing"
137            || checkpoint.parsed_file_count != 0
138            || checkpoint.committed_file_count != 0
139            || checkpoint.committed_symbol_count != 0
140            || checkpoint.committed_reference_count != 0
141            || checkpoint.committed_chunk_count != 0
142            || checkpoint.batch_count != 0
143            || checkpoint.last_path.is_some()
144        {
145            return Err(invalid_checkpoint(
146                "a content-equivalent restart must return a zero-progress indexing checkpoint",
147            ));
148        }
149        self.resume_from_checkpoint(checkpoint)
150    }
151
152    fn validate_pristine_resume_target(&self) -> Result<(), CodeIndexError> {
153        if !self.parsed_overflow.is_empty() {
154            return Err(invalid_checkpoint(
155                "uncommitted parsed overflow must be empty before durable resume",
156            ));
157        }
158        if self.cursor != 0 || self.next_batch_index != 1 {
159            return Err(invalid_checkpoint(
160                "resume requires a newly prepared plan before any parse batch",
161            ));
162        }
163
164        Ok(())
165    }
166
167    fn validate_checkpoint_content_identity(
168        &self,
169        checkpoint: &CodeIndexCheckpoint,
170    ) -> Result<(), CodeIndexError> {
171        let identity_matches = checkpoint.repository_id == self.registration.repository_id
172            && checkpoint.source_scope == self.source_scope
173            && checkpoint.tree_hash == self.tree_hash
174            && checkpoint.path_filters == self.path_filters
175            && checkpoint.language_filters == self.language_filters
176            && checkpoint.total_path_count == self.paths.len();
177        if !identity_matches {
178            return Err(invalid_checkpoint(
179                "repository, scope, source, filters, or path count does not match the plan",
180            ));
181        }
182        if checkpoint.resource_budget != self.resource_budget {
183            return Err(invalid_checkpoint(
184                "resource budget does not match the plan that will resume",
185            ));
186        }
187
188        Ok(())
189    }
190
191    fn validate_checkpoint_progress(
192        &self,
193        checkpoint: &CodeIndexCheckpoint,
194    ) -> Result<(), CodeIndexError> {
195        let requires_complete_prefix =
196            checkpoint_state_requires_complete_prefix(checkpoint.state.as_str())
197                .ok_or_else(|| invalid_checkpoint("state is not resumable"))?;
198        if checkpoint.parsed_file_count != checkpoint.committed_file_count {
199            return Err(invalid_checkpoint(
200                "parsed and committed file counts must be equal",
201            ));
202        }
203        let committed = checkpoint.committed_file_count;
204        if committed > self.paths.len() {
205            return Err(invalid_checkpoint(
206                "committed file count exceeds the planned path count",
207            ));
208        }
209        if requires_complete_prefix && committed != self.paths.len() {
210            return Err(invalid_checkpoint(
211                "finalizing or completed state requires every planned path to be committed",
212            ));
213        }
214        if committed == 0 {
215            if checkpoint.batch_count != 0 || checkpoint.last_path.is_some() {
216                return Err(invalid_checkpoint(
217                    "an empty committed prefix requires zero batches and no last path",
218                ));
219            }
220            return Ok(());
221        }
222        if checkpoint.batch_count == 0 || checkpoint.batch_count > committed {
223            return Err(invalid_checkpoint(
224                "batch count must describe one or more bounded committed batches",
225            ));
226        }
227        let expected_last_path = self.paths[committed - 1].path.as_str();
228        if checkpoint.last_path.as_deref() != Some(expected_last_path) {
229            return Err(invalid_checkpoint(
230                "last path does not identify the committed plan prefix",
231            ));
232        }
233
234        Ok(())
235    }
236
237    /// Parses the next bounded file batch while retaining at most one fetched
238    /// group's uncommitted row-budget overflow.
239    pub fn parse_next_batch(mut self) -> Result<(Self, Option<CodeIndexBatch>), CodeIndexError> {
240        if self.cursor >= self.paths.len() && self.parsed_overflow.is_empty() {
241            return Ok((self, None));
242        }
243
244        let mut build = SnapshotBuild::new_with_scope_filters(
245            &self.registration,
246            self.commit.clone(),
247            self.tree_hash.clone(),
248            SnapshotScopeFilters {
249                path_filters: self.path_filters.clone(),
250                language_filters: self.language_filters.clone(),
251            },
252            true,
253            self.paths.len(),
254            0,
255        );
256        build.bind_verified_source_scope(&self.source_scope)?;
257        let mut parsed_bytes = 0usize;
258        loop {
259            self.append_parsed_overflow(&mut build, &mut parsed_bytes);
260            if batch_budget_reached(&build, parsed_bytes, self.resource_budget) {
261                break;
262            }
263            if !self.fetch_and_parse_next_group(build.files.len(), parsed_bytes)? {
264                break;
265            }
266        }
267        identity::enrich_symbol_identities(&build.repository_id, &mut build.symbols);
268
269        let batch = CodeIndexBatch {
270            repository_id: build.repository_id,
271            source_scope: build.source_scope,
272            batch_index: self.next_batch_index,
273            parsed_byte_count: parsed_bytes,
274            files: build.files,
275            symbols: build.symbols,
276            references: build.references,
277            imports: build.imports,
278            dependencies: build.dependencies,
279            feature_flags: build.feature_flags,
280            framework_nodes: build.framework_nodes,
281            framework_edges: build.framework_edges,
282            routes: build.routes,
283            chunks: build.chunks,
284            diagnostics: build.diagnostics,
285        };
286        self.next_batch_index += 1;
287
288        Ok((self, Some(batch)))
289    }
290
291    fn append_parsed_overflow(&mut self, build: &mut SnapshotBuild, parsed_bytes: &mut usize) {
292        while let Some(parsed_file) = self.parsed_overflow.pop_front() {
293            *parsed_bytes = (*parsed_bytes).saturating_add(parsed_file.parsed_byte_count);
294            build.append_file_records(parsed_file.build);
295            if batch_budget_reached(build, *parsed_bytes, self.resource_budget) {
296                break;
297            }
298        }
299    }
300
301    fn fetch_and_parse_next_group(
302        &mut self,
303        batch_file_count: usize,
304        parsed_bytes: usize,
305    ) -> Result<bool, CodeIndexError> {
306        debug_assert!(self.parsed_overflow.is_empty());
307        if self.cursor >= self.paths.len() {
308            return Ok(false);
309        }
310        let fetch_end = next_fetch_end(self, batch_file_count, parsed_bytes);
311        if fetch_end == self.cursor {
312            return Ok(false);
313        }
314        let fetched_paths = self.paths[self.cursor..fetch_end]
315            .iter()
316            .map(|entry| entry.path.clone())
317            .collect::<Vec<_>>();
318        ensure_filesystem_paths_match_content_hashes(
319            &self.root,
320            &self.commit,
321            &fetched_paths,
322            &self.filesystem_path_hashes,
323        )?;
324        let blobs = source_snapshot_batch_bytes(
325            &self.root,
326            self.source_kind,
327            &self.commit,
328            &fetched_paths,
329        )?;
330        if blobs.len() != fetched_paths.len() {
331            return Err(CodeIndexError::InvalidInput(format!(
332                "source batch returned {} blobs for {} paths",
333                blobs.len(),
334                fetched_paths.len()
335            )));
336        }
337        ensure_filesystem_blobs_match_content_hashes(
338            &self.commit,
339            &fetched_paths,
340            &blobs,
341            &self.filesystem_path_hashes,
342        )?;
343        let parsed_files = parse_fetched_files(self, &fetched_paths, &blobs)?;
344        if parsed_files.len() != fetched_paths.len() {
345            return Err(CodeIndexError::InvalidInput(format!(
346                "parser batch returned {} files for {} paths",
347                parsed_files.len(),
348                fetched_paths.len()
349            )));
350        }
351        self.parsed_overflow
352            .extend(
353                blobs
354                    .iter()
355                    .zip(parsed_files)
356                    .map(|(bytes, build)| PendingParsedFile {
357                        parsed_byte_count: bytes.len(),
358                        build,
359                    }),
360            );
361        self.cursor = fetch_end;
362
363        Ok(true)
364    }
365}
366
367fn checkpoint_state_requires_complete_prefix(state: &str) -> Option<bool> {
368    if code_query_index_subphase(state).is_some()
369        || code_query_index_repair(state).is_some()
370        || code_reference_resolution(state).is_some()
371        || code_reference_resolution_query_index_repair(state).is_some()
372        || code_reference_search_query_index_repair(state).is_some()
373        || code_reference_search_rebuild(state).is_some()
374    {
375        return Some(true);
376    }
377    match state {
378        "indexing" => Some(false),
379        "finalizing:build_query_indexes"
380        | "finalizing:resolve_references"
381        | "finalizing:resolve_imports"
382        | "finalizing:resolve_call_targets"
383        | "finalizing:refresh_dependencies"
384        | "finalizing:rebuild_reference_search"
385        | "finalizing:rebuild_calls"
386        | "finalizing:publish_scope"
387        | "finalizing:resolve_workspace_imports"
388        | "finalizing:software_projection"
389        | "finalizing:partitioned_publish"
390        | "completed" => Some(true),
391        _ => None,
392    }
393}
394
395fn invalid_checkpoint(message: &str) -> CodeIndexError {
396    CodeIndexError::Invariant(format!("invalid code index resume checkpoint: {message}"))
397}
398
399fn parse_fetched_files(
400    plan: &CodeIndexPlan,
401    paths: &[String],
402    blobs: &[Vec<u8>],
403) -> Result<Vec<SnapshotBuild>, CodeIndexError> {
404    let worker_count = worker_count(paths.len(), total_blob_bytes(blobs));
405    if paths.len() <= 1 || worker_count <= 1 {
406        return paths
407            .iter()
408            .zip(blobs.iter())
409            .map(|(path, bytes)| parse_one_file(plan, path, bytes))
410            .collect();
411    }
412
413    let next_index = AtomicUsize::new(0);
414    let mut parsed = thread::scope(|scope| {
415        let handles = (0..worker_count)
416            .map(|_| {
417                let next_index = &next_index;
418                scope
419                    .spawn(move || parse_worker_queue(plan, paths, blobs, next_index, worker_count))
420            })
421            .collect::<Vec<_>>();
422        let mut parsed = Vec::with_capacity(paths.len());
423        for handle in handles {
424            let worker_output = handle.join().map_err(|_| {
425                CodeIndexError::InvalidInput("code parser worker panicked".to_owned())
426            })??;
427            parsed.extend(worker_output);
428        }
429
430        Ok::<_, CodeIndexError>(parsed)
431    })?;
432    parsed.sort_by_key(|(index, _)| *index);
433
434    Ok(parsed.into_iter().map(|(_, build)| build).collect())
435}
436
437fn parse_one_file(
438    plan: &CodeIndexPlan,
439    path: &str,
440    bytes: &[u8],
441) -> Result<SnapshotBuild, CodeIndexError> {
442    let mut build = SnapshotBuild::new_with_scope_filters(
443        &plan.registration,
444        plan.commit.clone(),
445        plan.tree_hash.clone(),
446        SnapshotScopeFilters {
447            path_filters: plan.path_filters.clone(),
448            language_filters: plan.language_filters.clone(),
449        },
450        true,
451        plan.paths.len(),
452        0,
453    );
454    build.bind_verified_source_scope(&plan.source_scope)?;
455    parse_indexed_file(&mut build, path, bytes)?;
456
457    Ok(build)
458}
459
460fn parse_worker_queue(
461    plan: &CodeIndexPlan,
462    paths: &[String],
463    blobs: &[Vec<u8>],
464    next_index: &AtomicUsize,
465    worker_count: usize,
466) -> Result<Vec<(usize, SnapshotBuild)>, CodeIndexError> {
467    let mut parsed = Vec::with_capacity(paths.len().div_ceil(worker_count));
468    loop {
469        let index = next_index.fetch_add(1, Ordering::Relaxed);
470        if index >= paths.len() {
471            break;
472        }
473        parsed.push((index, parse_one_file(plan, &paths[index], &blobs[index])?));
474    }
475
476    Ok(parsed)
477}
478
479fn total_blob_bytes(blobs: &[Vec<u8>]) -> usize {
480    blobs
481        .iter()
482        .fold(0usize, |total, blob| total.saturating_add(blob.len()))
483}
484
485fn worker_count(item_count: usize, total_bytes: usize) -> usize {
486    if item_count == 0 {
487        return 0;
488    }
489    if item_count < MIN_PARALLEL_PARSE_FILES && total_bytes < MIN_PARALLEL_PARSE_BYTES {
490        return 1;
491    }
492    let desired_workers = item_count
493        .div_ceil(TARGET_PARSE_FILES_PER_WORKER)
494        .max(total_bytes.div_ceil(TARGET_PARSE_BYTES_PER_WORKER))
495        .max(1);
496
497    thread::available_parallelism()
498        .map(usize::from)
499        .unwrap_or(1)
500        .min(item_count)
501        .min(desired_workers)
502}
503
504/// Prepares a full repository index as a bounded, checkpointable batch plan.
505pub fn prepare_full_index_plan(
506    registration: CodeRepositoryRegistration,
507    selector: CodeRepositorySelector,
508    resource_budget: CodeIndexResourceBudget,
509) -> Result<CodeIndexPlan, CodeIndexError> {
510    prepare_full_index_plan_with_workspace_detection(
511        registration,
512        selector,
513        resource_budget,
514        &CodeWorkspaceDetectionConfig::default(),
515    )
516}
517
518/// Prepares a full repository index plan with caller-controlled workspace
519/// detection metadata for finalization.
520pub fn prepare_full_index_plan_with_workspace_detection(
521    registration: CodeRepositoryRegistration,
522    selector: CodeRepositorySelector,
523    resource_budget: CodeIndexResourceBudget,
524    workspace_detection: &CodeWorkspaceDetectionConfig,
525) -> Result<CodeIndexPlan, CodeIndexError> {
526    let root = PathBuf::from(&registration.root_path);
527    let snapshot = scoped_source_snapshot(&registration, &selector, &root, &selector.ref_selector)?;
528    let filesystem_path_hashes = filesystem_plan_path_hashes(&snapshot)?;
529    let source_scope = crate::domain::code_snapshot_scope_id_with_workspace_detection(
530        &registration.repository_id,
531        &snapshot.tree_hash,
532        &snapshot.path_filters,
533        &snapshot.language_filters,
534        workspace_detection,
535    );
536    let workspaces = detect_workspaces_for_source_snapshot(
537        &snapshot.root,
538        snapshot.kind,
539        &snapshot.resolved_commit_sha,
540        &snapshot.entries,
541        &snapshot.path_filters,
542        workspace_detection,
543    );
544
545    Ok(CodeIndexPlan {
546        registration,
547        root: snapshot.root,
548        commit: snapshot.resolved_commit_sha,
549        tree_hash: snapshot.tree_hash,
550        source_scope,
551        path_filters: snapshot.path_filters,
552        language_filters: snapshot.language_filters,
553        source_kind: snapshot.kind,
554        filesystem_path_hashes,
555        paths: snapshot.entries,
556        workspaces,
557        cursor: 0,
558        parsed_overflow: VecDeque::new(),
559        next_batch_index: 1,
560        resource_budget,
561    })
562}
563
564fn filesystem_plan_path_hashes(
565    snapshot: &super::scope::ScopedSourceSnapshot,
566) -> Result<BTreeMap<String, String>, CodeIndexError> {
567    if !snapshot.kind.is_filesystem() {
568        return Ok(BTreeMap::new());
569    }
570    let paths = snapshot
571        .entries
572        .iter()
573        .map(|entry| entry.path.clone())
574        .collect::<Vec<_>>();
575    let path_hashes = filesystem_content_hashes_for_paths(&snapshot.root, &paths)?;
576    let tree_hash = filesystem_tree_hash_from_path_hashes(&path_hashes);
577    if tree_hash != snapshot.tree_hash {
578        return Err(CodeIndexError::InvalidInput(format!(
579            "filesystem source snapshot {} no longer matches planned filesystem content {tree_hash}",
580            snapshot.tree_hash
581        )));
582    }
583
584    Ok(path_hashes)
585}
586
587fn next_fetch_end(plan: &CodeIndexPlan, batch_file_count: usize, parsed_bytes: usize) -> usize {
588    let remaining_files = plan
589        .resource_budget
590        .max_files_per_batch
591        .saturating_sub(batch_file_count)
592        .max(1);
593    let file_limited_end = plan.paths.len().min(
594        plan.cursor
595            .saturating_add(GIT_BLOB_FETCH_GROUP.min(remaining_files)),
596    );
597    let remaining_bytes = plan
598        .resource_budget
599        .max_bytes_per_batch
600        .saturating_sub(parsed_bytes);
601    let mut byte_count = 0usize;
602    let mut end = plan.cursor;
603    while end < file_limited_end {
604        let entry_bytes = plan.paths[end].byte_count;
605        if end > plan.cursor && byte_count.saturating_add(entry_bytes) > remaining_bytes {
606            break;
607        }
608        byte_count = byte_count.saturating_add(entry_bytes);
609        end += 1;
610    }
611
612    if end == plan.cursor && batch_file_count == 0 {
613        return (plan.cursor + 1).min(plan.paths.len());
614    }
615
616    end
617}
618
619fn batch_row_count(build: &SnapshotBuild) -> usize {
620    build
621        .files
622        .len()
623        .saturating_add(build.symbols.len())
624        .saturating_add(build.references.len())
625        .saturating_add(build.imports.len())
626        .saturating_add(build.dependencies.len())
627        .saturating_add(build.feature_flags.len())
628        .saturating_add(build.routes.len())
629        .saturating_add(build.chunks.len())
630        .saturating_add(build.diagnostics.len())
631}
632
633fn batch_budget_reached(
634    build: &SnapshotBuild,
635    parsed_bytes: usize,
636    resource_budget: CodeIndexResourceBudget,
637) -> bool {
638    !build.files.is_empty()
639        && (build.files.len() >= resource_budget.max_files_per_batch
640            || parsed_bytes >= resource_budget.max_bytes_per_batch
641            || batch_row_count(build) >= resource_budget.max_rows_per_batch)
642}
643
644#[cfg(test)]
645#[path = "mod_tests.rs"]
646mod tests;