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