Skip to main content

relay_knowledge/code/
pipeline.rs

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/// Blocking plan for a checkpointed full repository index.
27#[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    /// Returns the durable session metadata that storage checkpoints.
44    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    /// Parses the next bounded file batch without retaining prior batches.
64    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
237/// Prepares a full repository index as a bounded, checkpointable batch plan.
238pub fn prepare_full_index_plan(
239    registration: CodeRepositoryRegistration,
240    selector: CodeRepositorySelector,
241    resource_budget: CodeIndexResourceBudget,
242) -> Result<CodeIndexPlan, CodeIndexError> {
243    let root = PathBuf::from(&registration.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                &registration,
255                &selector,
256                &ignore_rules,
257                &source_layout,
258            )
259            .is_none()
260        })
261        .collect::<Vec<_>>();
262    let path_filters = effective_index_path_filters(&registration, &selector, &source_layout);
263    let language_filters =
264        merged_filters(&registration.language_filters, &selector.language_filters);
265    let source_scope = code_snapshot_scope_id(
266        &registration.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            &registration,
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}