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 = Vec::with_capacity(paths.len());
140    for (path_chunk, blob_chunk) in paths.chunks(worker_count).zip(blobs.chunks(worker_count)) {
141        let chunk = thread::scope(|scope| {
142            let handles = path_chunk
143                .iter()
144                .zip(blob_chunk.iter())
145                .map(|(path, bytes)| scope.spawn(move || parse_one_file(plan, path, bytes)))
146                .collect::<Vec<_>>();
147            handles
148                .into_iter()
149                .map(|handle| {
150                    handle.join().map_err(|_| {
151                        CodeIndexError::InvalidInput("code parser worker panicked".to_owned())
152                    })?
153                })
154                .collect::<Result<Vec<_>, _>>()
155        })?;
156        parsed.extend(chunk);
157    }
158
159    Ok(parsed)
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 worker_count(item_count: usize) -> usize {
182    thread::available_parallelism()
183        .map(usize::from)
184        .unwrap_or(1)
185        .min(item_count)
186}
187
188/// Prepares a full repository index as a bounded, checkpointable batch plan.
189pub fn prepare_full_index_plan(
190    registration: CodeRepositoryRegistration,
191    selector: CodeRepositorySelector,
192    resource_budget: CodeIndexResourceBudget,
193) -> Result<CodeIndexPlan, CodeIndexError> {
194    let root = PathBuf::from(&registration.root_path);
195    let commit = resolve_ref(&root, &selector.ref_selector)?;
196    let tree_hash = resolve_tree(&root, &commit)?;
197    let ignore_rules = load_ignore_rules_from_commit(&root, &commit)?;
198    let paths = tracked_entries(&root, &commit)?
199        .into_iter()
200        .filter(|entry| {
201            selection_exclusion_reason(&entry.path, &registration, &selector, &ignore_rules)
202                .is_none()
203        })
204        .collect::<Vec<_>>();
205    let path_filters = merged_filters(&registration.path_filters, &selector.path_filters);
206    let language_filters =
207        merged_filters(&registration.language_filters, &selector.language_filters);
208    let source_scope = code_snapshot_scope_id(
209        &registration.repository_id,
210        &tree_hash,
211        &path_filters,
212        &language_filters,
213    );
214
215    Ok(CodeIndexPlan {
216        registration,
217        selector,
218        root,
219        commit,
220        tree_hash,
221        source_scope,
222        path_filters,
223        language_filters,
224        paths,
225        cursor: 0,
226        next_batch_index: 1,
227        resource_budget,
228    })
229}
230
231fn next_fetch_end(plan: &CodeIndexPlan, batch_file_count: usize, parsed_bytes: usize) -> usize {
232    let remaining_files = plan
233        .resource_budget
234        .max_files_per_batch
235        .saturating_sub(batch_file_count)
236        .max(1);
237    let file_limited_end = plan.paths.len().min(
238        plan.cursor
239            .saturating_add(GIT_BLOB_FETCH_GROUP.min(remaining_files)),
240    );
241    let remaining_bytes = plan
242        .resource_budget
243        .max_bytes_per_batch
244        .saturating_sub(parsed_bytes);
245    let mut byte_count = 0usize;
246    let mut end = plan.cursor;
247    while end < file_limited_end {
248        let entry_bytes = plan.paths[end].byte_count;
249        if end > plan.cursor && byte_count.saturating_add(entry_bytes) > remaining_bytes {
250            break;
251        }
252        byte_count = byte_count.saturating_add(entry_bytes);
253        end += 1;
254    }
255
256    if end == plan.cursor && batch_file_count == 0 {
257        return (plan.cursor + 1).min(plan.paths.len());
258    }
259
260    end
261}
262
263fn batch_row_count(build: &SnapshotBuild) -> usize {
264    build
265        .files
266        .len()
267        .saturating_add(build.symbols.len())
268        .saturating_add(build.references.len())
269        .saturating_add(build.imports.len())
270        .saturating_add(build.chunks.len())
271        .saturating_add(build.diagnostics.len())
272}
273
274fn merged_filters(left: &[String], right: &[String]) -> Vec<String> {
275    let mut merged = Vec::new();
276    for value in left.iter().chain(right.iter()) {
277        if !merged.contains(value) {
278            merged.push(value.clone());
279        }
280    }
281
282    merged
283}