Skip to main content

relay_knowledge/code/
pipeline.rs

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/// Blocking plan for a checkpointed full repository index.
28#[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    /// Returns the durable session metadata that storage checkpoints.
47    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    /// Parses the next bounded file batch without retaining prior batches.
67    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
257/// Prepares a full repository index as a bounded, checkpointable batch plan.
258pub fn prepare_full_index_plan(
259    registration: CodeRepositoryRegistration,
260    selector: CodeRepositorySelector,
261    resource_budget: CodeIndexResourceBudget,
262) -> Result<CodeIndexPlan, CodeIndexError> {
263    let root = PathBuf::from(&registration.root_path);
264    let snapshot = scoped_source_snapshot(&registration, &selector, &root, &selector.ref_selector)?;
265    let filesystem_path_hashes = filesystem_plan_path_hashes(&snapshot)?;
266    let source_scope = code_snapshot_scope_id(
267        &registration.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            &registration,
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}