1use 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#[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 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 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 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
504pub 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
518pub 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(®istration.root_path);
527 let snapshot = scoped_source_snapshot(®istration, &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 ®istration.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;