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::{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;
18
19/// Blocking plan for a checkpointed full repository index.
20#[derive(Debug, Clone)]
21pub struct CodeIndexPlan {
22    registration: CodeRepositoryRegistration,
23    selector: CodeRepositorySelector,
24    root: PathBuf,
25    commit: String,
26    tree_hash: String,
27    source_scope: String,
28    path_filters: Vec<String>,
29    language_filters: Vec<String>,
30    paths: Vec<GitTreeEntry>,
31    cursor: usize,
32    next_batch_index: usize,
33    resource_budget: CodeIndexResourceBudget,
34}
35
36impl CodeIndexPlan {
37    /// Returns the durable session metadata that storage checkpoints.
38    pub fn session(&self) -> CodeIndexSession {
39        CodeIndexSession {
40            repository_id: self.registration.repository_id.clone(),
41            source_scope: self.source_scope.clone(),
42            base_resolved_commit_sha: None,
43            resolved_commit_sha: self.commit.clone(),
44            tree_hash: self.tree_hash.clone(),
45            path_filters: self.path_filters.clone(),
46            language_filters: self.language_filters.clone(),
47            full_replace: true,
48            total_path_count: self.paths.len(),
49            changed_path_count: self.paths.len(),
50            skipped_unchanged_count: 0,
51            deleted_paths: Vec::new(),
52            tombstones: Vec::new(),
53            resource_budget: self.resource_budget,
54        }
55    }
56
57    /// Parses the next bounded file batch without retaining prior batches.
58    pub fn parse_next_batch(mut self) -> Result<(Self, Option<CodeIndexBatch>), CodeIndexError> {
59        if self.cursor >= self.paths.len() {
60            return Ok((self, None));
61        }
62
63        let mut build = SnapshotBuild::new_with_selector(
64            &self.registration,
65            &self.selector,
66            self.commit.clone(),
67            self.tree_hash.clone(),
68            true,
69            self.paths.len(),
70            0,
71        );
72        let mut parsed_bytes = 0usize;
73        while self.cursor < self.paths.len() {
74            let fetch_end = next_fetch_end(&self, build.files.len(), parsed_bytes);
75            if fetch_end == self.cursor {
76                break;
77            }
78            let fetched_paths = self.paths[self.cursor..fetch_end]
79                .iter()
80                .map(|entry| entry.path.clone())
81                .collect::<Vec<_>>();
82            let blobs = git_batch_blobs(&self.root, &self.commit, &fetched_paths)?;
83            let parsed_files = parse_fetched_files(&self, &fetched_paths, &blobs)?;
84            for (bytes, parsed_file) in blobs.iter().zip(parsed_files) {
85                parsed_bytes = parsed_bytes.saturating_add(bytes.len());
86                build.append_file_records(parsed_file);
87                self.cursor += 1;
88
89                if !build.files.is_empty()
90                    && (build.files.len() >= self.resource_budget.max_files_per_batch
91                        || parsed_bytes >= self.resource_budget.max_bytes_per_batch
92                        || batch_row_count(&build) >= self.resource_budget.max_rows_per_batch)
93                {
94                    break;
95                }
96            }
97            if !build.files.is_empty()
98                && (build.files.len() >= self.resource_budget.max_files_per_batch
99                    || parsed_bytes >= self.resource_budget.max_bytes_per_batch
100                    || batch_row_count(&build) >= self.resource_budget.max_rows_per_batch)
101            {
102                break;
103            }
104        }
105        identity::enrich_symbol_identities(&build.repository_id, &mut build.symbols);
106
107        let batch = CodeIndexBatch {
108            repository_id: build.repository_id,
109            source_scope: build.source_scope,
110            batch_index: self.next_batch_index,
111            parsed_byte_count: parsed_bytes,
112            files: build.files,
113            symbols: build.symbols,
114            references: build.references,
115            imports: build.imports,
116            chunks: build.chunks,
117            diagnostics: build.diagnostics,
118        };
119        self.next_batch_index += 1;
120
121        Ok((self, Some(batch)))
122    }
123}
124
125fn parse_fetched_files(
126    plan: &CodeIndexPlan,
127    paths: &[String],
128    blobs: &[Vec<u8>],
129) -> Result<Vec<SnapshotBuild>, CodeIndexError> {
130    let worker_count = worker_count(paths.len());
131    if paths.len() <= 1 || worker_count <= 1 {
132        return paths
133            .iter()
134            .zip(blobs.iter())
135            .map(|(path, bytes)| parse_one_file(plan, path, bytes))
136            .collect();
137    }
138
139    let mut parsed = thread::scope(|scope| {
140        let handles = (0..worker_count)
141            .map(|worker_index| {
142                scope.spawn(move || {
143                    parse_worker_stride(plan, paths, blobs, worker_index, worker_count)
144                })
145            })
146            .collect::<Vec<_>>();
147        let mut parsed = Vec::with_capacity(paths.len());
148        for handle in handles {
149            let worker_output = handle.join().map_err(|_| {
150                CodeIndexError::InvalidInput("code parser worker panicked".to_owned())
151            })??;
152            parsed.extend(worker_output);
153        }
154
155        Ok::<_, CodeIndexError>(parsed)
156    })?;
157    parsed.sort_by_key(|(index, _)| *index);
158
159    Ok(parsed.into_iter().map(|(_, build)| build).collect())
160}
161
162fn parse_one_file(
163    plan: &CodeIndexPlan,
164    path: &str,
165    bytes: &[u8],
166) -> Result<SnapshotBuild, CodeIndexError> {
167    let mut build = SnapshotBuild::new_with_selector(
168        &plan.registration,
169        &plan.selector,
170        plan.commit.clone(),
171        plan.tree_hash.clone(),
172        true,
173        plan.paths.len(),
174        0,
175    );
176    parse_indexed_file(&mut build, path, bytes)?;
177
178    Ok(build)
179}
180
181fn parse_worker_stride(
182    plan: &CodeIndexPlan,
183    paths: &[String],
184    blobs: &[Vec<u8>],
185    worker_index: usize,
186    worker_count: usize,
187) -> Result<Vec<(usize, SnapshotBuild)>, CodeIndexError> {
188    let mut parsed = Vec::new();
189    let mut index = worker_index;
190    while index < paths.len() {
191        parsed.push((index, parse_one_file(plan, &paths[index], &blobs[index])?));
192        index += worker_count;
193    }
194
195    Ok(parsed)
196}
197
198fn worker_count(item_count: usize) -> usize {
199    thread::available_parallelism()
200        .map(usize::from)
201        .unwrap_or(1)
202        .min(item_count)
203}
204
205/// Prepares a full repository index as a bounded, checkpointable batch plan.
206pub fn prepare_full_index_plan(
207    registration: CodeRepositoryRegistration,
208    selector: CodeRepositorySelector,
209    resource_budget: CodeIndexResourceBudget,
210) -> Result<CodeIndexPlan, CodeIndexError> {
211    let root = PathBuf::from(&registration.root_path);
212    let commit = resolve_ref(&root, &selector.ref_selector)?;
213    let tree_hash = resolve_tree(&root, &commit)?;
214    let ignore_rules = load_ignore_rules_from_commit(&root, &commit)?;
215    let paths = tracked_entries(&root, &commit)?
216        .into_iter()
217        .filter(|entry| {
218            selection_exclusion_reason(&entry.path, &registration, &selector, &ignore_rules)
219                .is_none()
220        })
221        .collect::<Vec<_>>();
222    let path_filters = merged_filters(&registration.path_filters, &selector.path_filters);
223    let language_filters =
224        merged_filters(&registration.language_filters, &selector.language_filters);
225    let source_scope = code_snapshot_scope_id(
226        &registration.repository_id,
227        &tree_hash,
228        &path_filters,
229        &language_filters,
230    );
231
232    Ok(CodeIndexPlan {
233        registration,
234        selector,
235        root,
236        commit,
237        tree_hash,
238        source_scope,
239        path_filters,
240        language_filters,
241        paths,
242        cursor: 0,
243        next_batch_index: 1,
244        resource_budget,
245    })
246}
247
248fn next_fetch_end(plan: &CodeIndexPlan, batch_file_count: usize, parsed_bytes: usize) -> usize {
249    let remaining_files = plan
250        .resource_budget
251        .max_files_per_batch
252        .saturating_sub(batch_file_count)
253        .max(1);
254    let file_limited_end = plan.paths.len().min(
255        plan.cursor
256            .saturating_add(GIT_BLOB_FETCH_GROUP.min(remaining_files)),
257    );
258    let remaining_bytes = plan
259        .resource_budget
260        .max_bytes_per_batch
261        .saturating_sub(parsed_bytes);
262    let mut byte_count = 0usize;
263    let mut end = plan.cursor;
264    while end < file_limited_end {
265        let entry_bytes = plan.paths[end].byte_count;
266        if end > plan.cursor && byte_count.saturating_add(entry_bytes) > remaining_bytes {
267            break;
268        }
269        byte_count = byte_count.saturating_add(entry_bytes);
270        end += 1;
271    }
272
273    if end == plan.cursor && batch_file_count == 0 {
274        return (plan.cursor + 1).min(plan.paths.len());
275    }
276
277    end
278}
279
280fn batch_row_count(build: &SnapshotBuild) -> usize {
281    build
282        .files
283        .len()
284        .saturating_add(build.symbols.len())
285        .saturating_add(build.references.len())
286        .saturating_add(build.imports.len())
287        .saturating_add(build.chunks.len())
288        .saturating_add(build.diagnostics.len())
289}
290
291fn merged_filters(left: &[String], right: &[String]) -> Vec<String> {
292    let mut merged = Vec::new();
293    for value in left.iter().chain(right.iter()) {
294        if !merged.contains(value) {
295            merged.push(value.clone());
296        }
297    }
298
299    merged
300}