1use std::{collections::BTreeMap, path::PathBuf, thread};
2
3use crate::domain::{
4 CodeIndexBatch, CodeIndexResourceBudget, CodeIndexSession, CodeRepositoryRegistration,
5 CodeRepositorySelector, code_snapshot_scope_id,
6};
7
8use super::{
9 CodeIndexError,
10 changes::GitTreeEntry,
11 identity, parse_indexed_file,
12 scope::scoped_source_snapshot,
13 snapshot::{SnapshotBuild, SnapshotScopeFilters},
14 source::{
15 RepositorySourceKind, ensure_filesystem_blobs_match_content_hashes,
16 ensure_filesystem_paths_match_content_hashes, filesystem_content_hashes_for_paths,
17 filesystem_tree_hash_from_path_hashes, source_snapshot_batch_bytes,
18 },
19};
20
21const GIT_BLOB_FETCH_GROUP: usize = CodeIndexResourceBudget::DEFAULT_MAX_FILES_PER_BATCH;
22const MIN_PARALLEL_PARSE_FILES: usize = 12;
23const MIN_PARALLEL_PARSE_BYTES: usize = 256 * 1024;
24const TARGET_PARSE_FILES_PER_WORKER: usize = 16;
25const TARGET_PARSE_BYTES_PER_WORKER: usize = 512 * 1024;
26
27#[derive(Debug, Clone)]
29pub struct CodeIndexPlan {
30 registration: CodeRepositoryRegistration,
31 root: PathBuf,
32 commit: String,
33 tree_hash: String,
34 source_scope: String,
35 path_filters: Vec<String>,
36 language_filters: Vec<String>,
37 source_kind: RepositorySourceKind,
38 filesystem_path_hashes: BTreeMap<String, String>,
39 paths: Vec<GitTreeEntry>,
40 cursor: usize,
41 next_batch_index: usize,
42 resource_budget: CodeIndexResourceBudget,
43}
44
45impl CodeIndexPlan {
46 pub fn session(&self) -> CodeIndexSession {
48 CodeIndexSession {
49 repository_id: self.registration.repository_id.clone(),
50 source_scope: self.source_scope.clone(),
51 base_resolved_commit_sha: None,
52 resolved_commit_sha: self.commit.clone(),
53 tree_hash: self.tree_hash.clone(),
54 path_filters: self.path_filters.clone(),
55 language_filters: self.language_filters.clone(),
56 full_replace: true,
57 total_path_count: self.paths.len(),
58 changed_path_count: self.paths.len(),
59 skipped_unchanged_count: 0,
60 deleted_paths: Vec::new(),
61 tombstones: Vec::new(),
62 resource_budget: self.resource_budget,
63 }
64 }
65
66 pub fn parse_next_batch(mut self) -> Result<(Self, Option<CodeIndexBatch>), CodeIndexError> {
68 if self.cursor >= self.paths.len() {
69 return Ok((self, None));
70 }
71
72 let mut build = SnapshotBuild::new_with_scope_filters(
73 &self.registration,
74 self.commit.clone(),
75 self.tree_hash.clone(),
76 SnapshotScopeFilters {
77 path_filters: self.path_filters.clone(),
78 language_filters: self.language_filters.clone(),
79 },
80 true,
81 self.paths.len(),
82 0,
83 );
84 let mut parsed_bytes = 0usize;
85 while self.cursor < self.paths.len() {
86 let fetch_end = next_fetch_end(&self, build.files.len(), parsed_bytes);
87 if fetch_end == self.cursor {
88 break;
89 }
90 let fetched_paths = self.paths[self.cursor..fetch_end]
91 .iter()
92 .map(|entry| entry.path.clone())
93 .collect::<Vec<_>>();
94 ensure_filesystem_paths_match_content_hashes(
95 &self.root,
96 &self.commit,
97 &fetched_paths,
98 &self.filesystem_path_hashes,
99 )?;
100 let blobs = source_snapshot_batch_bytes(
101 &self.root,
102 self.source_kind,
103 &self.commit,
104 &fetched_paths,
105 )?;
106 ensure_filesystem_blobs_match_content_hashes(
107 &self.commit,
108 &fetched_paths,
109 &blobs,
110 &self.filesystem_path_hashes,
111 )?;
112 let parsed_files = parse_fetched_files(&self, &fetched_paths, &blobs)?;
113 for (bytes, parsed_file) in blobs.iter().zip(parsed_files) {
114 parsed_bytes = parsed_bytes.saturating_add(bytes.len());
115 build.append_file_records(parsed_file);
116 self.cursor += 1;
117
118 if !build.files.is_empty()
119 && (build.files.len() >= self.resource_budget.max_files_per_batch
120 || parsed_bytes >= self.resource_budget.max_bytes_per_batch
121 || batch_row_count(&build) >= self.resource_budget.max_rows_per_batch)
122 {
123 break;
124 }
125 }
126 if !build.files.is_empty()
127 && (build.files.len() >= self.resource_budget.max_files_per_batch
128 || parsed_bytes >= self.resource_budget.max_bytes_per_batch
129 || batch_row_count(&build) >= self.resource_budget.max_rows_per_batch)
130 {
131 break;
132 }
133 }
134 identity::enrich_symbol_identities(&build.repository_id, &mut build.symbols);
135
136 let batch = CodeIndexBatch {
137 repository_id: build.repository_id,
138 source_scope: build.source_scope,
139 batch_index: self.next_batch_index,
140 parsed_byte_count: parsed_bytes,
141 files: build.files,
142 symbols: build.symbols,
143 references: build.references,
144 imports: build.imports,
145 dependencies: build.dependencies,
146 feature_flags: build.feature_flags,
147 chunks: build.chunks,
148 diagnostics: build.diagnostics,
149 };
150 self.next_batch_index += 1;
151
152 Ok((self, Some(batch)))
153 }
154}
155
156fn parse_fetched_files(
157 plan: &CodeIndexPlan,
158 paths: &[String],
159 blobs: &[Vec<u8>],
160) -> Result<Vec<SnapshotBuild>, CodeIndexError> {
161 let worker_count = worker_count(paths.len(), total_blob_bytes(blobs));
162 if paths.len() <= 1 || worker_count <= 1 {
163 return paths
164 .iter()
165 .zip(blobs.iter())
166 .map(|(path, bytes)| parse_one_file(plan, path, bytes))
167 .collect();
168 }
169
170 let mut parsed = thread::scope(|scope| {
171 let handles = (0..worker_count)
172 .map(|worker_index| {
173 scope.spawn(move || {
174 parse_worker_stride(plan, paths, blobs, worker_index, worker_count)
175 })
176 })
177 .collect::<Vec<_>>();
178 let mut parsed = Vec::with_capacity(paths.len());
179 for handle in handles {
180 let worker_output = handle.join().map_err(|_| {
181 CodeIndexError::InvalidInput("code parser worker panicked".to_owned())
182 })??;
183 parsed.extend(worker_output);
184 }
185
186 Ok::<_, CodeIndexError>(parsed)
187 })?;
188 parsed.sort_by_key(|(index, _)| *index);
189
190 Ok(parsed.into_iter().map(|(_, build)| build).collect())
191}
192
193fn parse_one_file(
194 plan: &CodeIndexPlan,
195 path: &str,
196 bytes: &[u8],
197) -> Result<SnapshotBuild, CodeIndexError> {
198 let mut build = SnapshotBuild::new_with_scope_filters(
199 &plan.registration,
200 plan.commit.clone(),
201 plan.tree_hash.clone(),
202 SnapshotScopeFilters {
203 path_filters: plan.path_filters.clone(),
204 language_filters: plan.language_filters.clone(),
205 },
206 true,
207 plan.paths.len(),
208 0,
209 );
210 parse_indexed_file(&mut build, path, bytes)?;
211
212 Ok(build)
213}
214
215fn parse_worker_stride(
216 plan: &CodeIndexPlan,
217 paths: &[String],
218 blobs: &[Vec<u8>],
219 worker_index: usize,
220 worker_count: usize,
221) -> Result<Vec<(usize, SnapshotBuild)>, CodeIndexError> {
222 let mut parsed = Vec::new();
223 let mut index = worker_index;
224 while index < paths.len() {
225 parsed.push((index, parse_one_file(plan, &paths[index], &blobs[index])?));
226 index += worker_count;
227 }
228
229 Ok(parsed)
230}
231
232fn total_blob_bytes(blobs: &[Vec<u8>]) -> usize {
233 blobs
234 .iter()
235 .fold(0usize, |total, blob| total.saturating_add(blob.len()))
236}
237
238fn worker_count(item_count: usize, total_bytes: usize) -> usize {
239 if item_count == 0 {
240 return 0;
241 }
242 if item_count < MIN_PARALLEL_PARSE_FILES && total_bytes < MIN_PARALLEL_PARSE_BYTES {
243 return 1;
244 }
245 let desired_workers = item_count
246 .div_ceil(TARGET_PARSE_FILES_PER_WORKER)
247 .max(total_bytes.div_ceil(TARGET_PARSE_BYTES_PER_WORKER))
248 .max(1);
249
250 thread::available_parallelism()
251 .map(usize::from)
252 .unwrap_or(1)
253 .min(item_count)
254 .min(desired_workers)
255}
256
257pub fn prepare_full_index_plan(
259 registration: CodeRepositoryRegistration,
260 selector: CodeRepositorySelector,
261 resource_budget: CodeIndexResourceBudget,
262) -> Result<CodeIndexPlan, CodeIndexError> {
263 let root = PathBuf::from(®istration.root_path);
264 let snapshot = scoped_source_snapshot(®istration, &selector, &root, &selector.ref_selector)?;
265 let filesystem_path_hashes = filesystem_plan_path_hashes(&snapshot)?;
266 let source_scope = code_snapshot_scope_id(
267 ®istration.repository_id,
268 &snapshot.tree_hash,
269 &snapshot.path_filters,
270 &snapshot.language_filters,
271 );
272
273 Ok(CodeIndexPlan {
274 registration,
275 root: snapshot.root,
276 commit: snapshot.resolved_commit_sha,
277 tree_hash: snapshot.tree_hash,
278 source_scope,
279 path_filters: snapshot.path_filters,
280 language_filters: snapshot.language_filters,
281 source_kind: snapshot.kind,
282 filesystem_path_hashes,
283 paths: snapshot.entries,
284 cursor: 0,
285 next_batch_index: 1,
286 resource_budget,
287 })
288}
289
290fn filesystem_plan_path_hashes(
291 snapshot: &super::scope::ScopedSourceSnapshot,
292) -> Result<BTreeMap<String, String>, CodeIndexError> {
293 if !snapshot.kind.is_filesystem() {
294 return Ok(BTreeMap::new());
295 }
296 let paths = snapshot
297 .entries
298 .iter()
299 .map(|entry| entry.path.clone())
300 .collect::<Vec<_>>();
301 let path_hashes = filesystem_content_hashes_for_paths(&snapshot.root, &paths)?;
302 let tree_hash = filesystem_tree_hash_from_path_hashes(&path_hashes);
303 if tree_hash != snapshot.tree_hash {
304 return Err(CodeIndexError::InvalidInput(format!(
305 "filesystem source snapshot {} no longer matches planned filesystem content {tree_hash}",
306 snapshot.tree_hash
307 )));
308 }
309
310 Ok(path_hashes)
311}
312
313fn next_fetch_end(plan: &CodeIndexPlan, batch_file_count: usize, parsed_bytes: usize) -> usize {
314 let remaining_files = plan
315 .resource_budget
316 .max_files_per_batch
317 .saturating_sub(batch_file_count)
318 .max(1);
319 let file_limited_end = plan.paths.len().min(
320 plan.cursor
321 .saturating_add(GIT_BLOB_FETCH_GROUP.min(remaining_files)),
322 );
323 let remaining_bytes = plan
324 .resource_budget
325 .max_bytes_per_batch
326 .saturating_sub(parsed_bytes);
327 let mut byte_count = 0usize;
328 let mut end = plan.cursor;
329 while end < file_limited_end {
330 let entry_bytes = plan.paths[end].byte_count;
331 if end > plan.cursor && byte_count.saturating_add(entry_bytes) > remaining_bytes {
332 break;
333 }
334 byte_count = byte_count.saturating_add(entry_bytes);
335 end += 1;
336 }
337
338 if end == plan.cursor && batch_file_count == 0 {
339 return (plan.cursor + 1).min(plan.paths.len());
340 }
341
342 end
343}
344
345fn batch_row_count(build: &SnapshotBuild) -> usize {
346 build
347 .files
348 .len()
349 .saturating_add(build.symbols.len())
350 .saturating_add(build.references.len())
351 .saturating_add(build.imports.len())
352 .saturating_add(build.dependencies.len())
353 .saturating_add(build.feature_flags.len())
354 .saturating_add(build.chunks.len())
355 .saturating_add(build.diagnostics.len())
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn parser_worker_count_keeps_tiny_batches_serial() {
364 assert_eq!(worker_count(7, 32 * 1024), 1);
365 }
366
367 #[test]
368 fn parser_worker_count_scales_with_bounded_batch_work() {
369 let available = thread::available_parallelism()
370 .map(usize::from)
371 .unwrap_or(1);
372 let workers = worker_count(96, 4 * 1024 * 1024);
373
374 assert_eq!(workers, available.min(8).min(96));
375 assert!(workers >= 1);
376 }
377
378 #[test]
379 fn parser_worker_count_caps_thread_fanout_for_small_byte_batches() {
380 let available = thread::available_parallelism()
381 .map(usize::from)
382 .unwrap_or(1);
383 let workers = worker_count(40, 128 * 1024);
384
385 assert_eq!(workers, available.min(3).min(40));
386 }
387
388 #[test]
389 fn batch_row_count_includes_feature_flags() {
390 let registration =
391 CodeRepositoryRegistration::new("repo", "fixture", "/tmp/repo", Vec::new(), Vec::new())
392 .expect("registration should validate");
393 let mut build = SnapshotBuild::new(
394 ®istration,
395 "commit".to_owned(),
396 "tree".to_owned(),
397 true,
398 1,
399 0,
400 );
401 build.feature_flags = crate::code::feature_flags::extract_feature_flags(
402 crate::code::feature_flags::FeatureFlagFileInput {
403 repository_id: &build.repository_id,
404 source_scope: &build.source_scope,
405 file_id: "file",
406 path: "src/lib.rs",
407 language_id: "rust",
408 content: "if env::var(\"CHECKOUT_V2\").is_ok() && env::var(\"PAYMENTS_V2\").is_ok() {}",
409 config_facts: &[],
410 },
411 )
412 .expect("feature flags should extract");
413
414 assert_eq!(batch_row_count(&build), 2);
415 }
416}