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