Skip to main content

sinter_store/
update.rs

1//! Incremental derivation: apply changed/removed file facts and keep every
2//! derived table (nodes, edges, name/trigram/token indexes, unresolved refs)
3//! consistent for exactly the touched files. Nothing here scans the corpus.
4
5use std::collections::BTreeSet;
6
7use redb::{ReadableDatabase, ReadableMultimapTable, ReadableTable};
8use sinter_core::{Edge, Evidence, FileFacts, Reference};
9
10use crate::error::StoreError;
11use crate::search::{node_tokens, trigrams};
12use crate::store::{
13    FILE_FACTS, FILE_HASH, IMPORTS, IN_EDGES, INTERN, INTERN_REV, META, NAME_NODES, NAME_REFS,
14    NODES, OUT_EDGES, PENDING, Store, TOKENS_WORDS, TRIGRAMS, UNRESOLVED,
15};
16
17/// FileFacts blobs are zstd-compressed postcard (19% of stored bytes at
18/// level 1 cost ~µs per file; read only on incremental paths, never hot).
19pub(crate) fn encode_facts(facts: &FileFacts) -> Result<Vec<u8>, StoreError> {
20    let raw = postcard::to_allocvec(facts)?;
21    zstd::encode_all(raw.as_slice(), 1).map_err(StoreError::Compress)
22}
23
24pub(crate) fn decode_facts(bytes: &[u8]) -> Result<FileFacts, StoreError> {
25    let raw = zstd::decode_all(bytes).map_err(StoreError::Compress)?;
26    Ok(postcard::from_bytes(&raw)?)
27}
28
29/// What an update invalidated: definition names whose binding targets may
30/// have changed, and files that held resolution edges into the touched
31/// files (their import/module bindings were torn down and must re-resolve
32/// even when no name they use changed — e.g. package imports bound to a
33/// file node).
34#[derive(Debug, Default)]
35pub struct NameDelta {
36    pub def_names: BTreeSet<String>,
37    pub dependent_files: BTreeSet<String>,
38}
39
40/// File a node id belongs to: `{file}#...` or a bare file-node id.
41fn file_of_id(id: &str) -> &str {
42    id.split_once('#').map_or(id, |(file, _)| file)
43}
44
45/// Everything derivable from one file's facts without table access,
46/// precomputed off the writer thread: serialization and index tokenization
47/// were 55% of a cold build inside the single write transaction.
48struct PreparedFile {
49    /// zstd facts blob for FILE_FACTS.
50    encoded: Vec<u8>,
51    /// Per node (aligned with `facts.nodes`): postcard blob, trigram list,
52    /// token set.
53    nodes: Vec<(Vec<u8>, Vec<String>, BTreeSet<String>)>,
54    /// Postcard blobs aligned with `facts.contains`.
55    edges: Vec<Vec<u8>>,
56    /// Postcard blobs of the Imports-relation references, in order.
57    imports: Vec<Vec<u8>>,
58}
59
60fn prepare_file(facts: &FileFacts) -> Result<PreparedFile, StoreError> {
61    Ok(PreparedFile {
62        encoded: encode_facts(facts)?,
63        nodes: facts
64            .nodes
65            .iter()
66            .map(|n| Ok((postcard::to_allocvec(n)?, trigrams(&n.name), node_tokens(n))))
67            .collect::<Result<_, StoreError>>()?,
68        edges: facts
69            .contains
70            .iter()
71            .map(postcard::to_allocvec)
72            .collect::<Result<_, _>>()?,
73        imports: facts
74            .references
75            .iter()
76            .filter(|r| r.relation == sinter_core::Relation::Imports)
77            .map(postcard::to_allocvec)
78            .collect::<Result<_, _>>()?,
79    })
80}
81
82/// Order-preserving parallel map over all available cores. Plain
83/// std::thread::scope: this crate has no rayon, and one work-stealing
84/// index is all a per-file map needs.
85fn par_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
86    let workers = std::thread::available_parallelism()
87        .map_or(1, |n| n.get())
88        .min(items.len());
89    if workers <= 1 {
90        return items.iter().map(f).collect();
91    }
92    let next = std::sync::atomic::AtomicUsize::new(0);
93    let chunks: Vec<Vec<(usize, R)>> = std::thread::scope(|s| {
94        let handles: Vec<_> = (0..workers)
95            .map(|_| {
96                s.spawn(|| {
97                    let mut out = Vec::new();
98                    loop {
99                        let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
100                        let Some(item) = items.get(i) else { break };
101                        out.push((i, f(item)));
102                    }
103                    out
104                })
105            })
106            .collect();
107        handles
108            .into_iter()
109            .map(|h| h.join().expect("prepare worker panicked"))
110            .collect()
111    });
112    let mut slots: Vec<Option<R>> = std::iter::repeat_with(|| None).take(items.len()).collect();
113    for chunk in chunks {
114        for (i, r) in chunk {
115            slots[i] = Some(r);
116        }
117    }
118    slots
119        .into_iter()
120        .map(|s| s.expect("every index visited"))
121        .collect()
122}
123
124/// Pending-delta wire form: (def_names, dependent_files).
125type PendingSets = (BTreeSet<String>, BTreeSet<String>);
126
127/// Files whose prepared rows are buffered at once during install.
128/// Bounds precompute memory to a few hundred MB on huge corpora while
129/// keeping per-chunk sorted index inserts and full-core parallelism.
130const PREPARE_CHUNK: usize = 512;
131
132impl Store {
133    /// Apply extraction results: `changed` files get their derived state
134    /// replaced, `removed` files get theirs deleted. One transaction.
135    ///
136    /// The returned delta is also merged into a persistent pending record
137    /// committed atomically with this transaction; it survives a crash
138    /// between this call and the resolution pass, and the pipeline clears
139    /// it (see [`Store::clear_pending_delta`]) only after hash stamps
140    /// commit. Replaying it on the next build recovers dependent-file
141    /// bindings that would otherwise be lost with their in-edges.
142    pub fn update_files(
143        &self,
144        changed: &[FileFacts],
145        removed: &[String],
146    ) -> Result<NameDelta, StoreError> {
147        let mut delta = NameDelta::default();
148        // Clean build: nothing touched, no write transaction.
149        if changed.is_empty() && removed.is_empty() {
150            return Ok(delta);
151        }
152        let txn = self.db.begin_write()?;
153        {
154            let mut nodes = txn.open_table(NODES)?;
155            let mut facts_table = txn.open_table(FILE_FACTS)?;
156            let mut hash_table = txn.open_table(FILE_HASH)?;
157            let mut out = txn.open_multimap_table(OUT_EDGES)?;
158            let mut inn = txn.open_multimap_table(IN_EDGES)?;
159            let mut unresolved = txn.open_multimap_table(UNRESOLVED)?;
160            let mut name_refs = txn.open_multimap_table(NAME_REFS)?;
161            let mut name_nodes = txn.open_multimap_table(NAME_NODES)?;
162            let mut grams = txn.open_multimap_table(TRIGRAMS)?;
163            let mut tokens = txn.open_multimap_table(TOKENS_WORDS)?;
164            let mut imports = txn.open_multimap_table(IMPORTS)?;
165            let mut intern = txn.open_table(INTERN)?;
166            let mut intern_rev = txn.open_table(INTERN_REV)?;
167            let mut meta = txn.open_table(META)?;
168            let mut pending = txn.open_table(PENDING)?;
169            let mut next_intern = meta.get("intern_next")?.map(|g| g.value()).unwrap_or(0);
170
171            let touched: Vec<&str> = changed
172                .iter()
173                .map(|f| f.file.as_str())
174                .chain(removed.iter().map(String::as_str))
175                .collect();
176
177            // Tear down old derived state for every touched file.
178            for file in &touched {
179                let Some(old): Option<FileFacts> = facts_table
180                    .get(*file)?
181                    .map(|g| decode_facts(g.value()))
182                    .transpose()?
183                else {
184                    continue;
185                };
186                for node in &old.nodes {
187                    let id = node.id.as_str();
188                    // Bidirectional edge cleanup: every edge listed on this
189                    // node also lives on its opposite endpoint's list.
190                    let out_bytes: Vec<Vec<u8>> = collect_values(out.get(id)?)?;
191                    for bytes in out_bytes {
192                        let edge: Edge = postcard::from_bytes(&bytes)?;
193                        inn.remove(edge.dst.as_str(), bytes.as_slice())?;
194                    }
195                    let in_bytes: Vec<Vec<u8>> = collect_values(inn.get(id)?)?;
196                    for bytes in in_bytes {
197                        let edge: Edge = postcard::from_bytes(&bytes)?;
198                        out.remove(edge.src.as_str(), bytes.as_slice())?;
199                        // The src file just lost a binding into this file;
200                        // it must re-resolve even if no name it uses changed.
201                        if edge.evidence != Evidence::Structural {
202                            delta
203                                .dependent_files
204                                .insert(file_of_id(edge.src.as_str()).to_string());
205                        }
206                    }
207                    out.remove_all(id)?;
208                    inn.remove_all(id)?;
209                    nodes.remove(id)?;
210                    let interned_opt = intern_rev.get(id)?.map(|g| g.value());
211                    if let Some(interned) = interned_opt {
212                        name_nodes.remove(node.name.as_str(), interned)?;
213                        for gram in trigrams(&node.name) {
214                            grams.remove(gram.as_str(), interned)?;
215                        }
216                        for word in node_tokens(node) {
217                            tokens.remove(word.as_str(), interned)?;
218                        }
219                        intern.remove(interned)?;
220                        intern_rev.remove(id)?;
221                    }
222                    delta.def_names.insert(node.name.clone());
223                }
224                for r in &old.references {
225                    name_refs.remove(r.name.as_str(), *file)?;
226                }
227                imports.remove_all(*file)?;
228                unresolved.remove_all(*file)?;
229                facts_table.remove(*file)?;
230                hash_table.remove(*file)?;
231            }
232
233            // Install new derived state for changed files. CPU-heavy
234            // derivation (postcard, zstd, trigrams, tokens) runs parallel
235            // off the writer thread, chunked so the buffered rows stay a
236            // few hundred MB instead of one prepared corpus (peak-RSS
237            // budget). Multimap index rows are inserted sorted by key per
238            // chunk: keyed B-tree inserts in key order touch far fewer
239            // pages than per-node interleaving.
240            for chunk in changed.chunks(PREPARE_CHUNK) {
241                let prepared: Vec<PreparedFile> = par_map(chunk, prepare_file)
242                    .into_iter()
243                    .collect::<Result<_, _>>()?;
244                let mut name_pairs: Vec<(&str, u32)> = Vec::new();
245                let mut gram_pairs: Vec<(&str, u32)> = Vec::new();
246                let mut token_pairs: Vec<(&str, u32)> = Vec::new();
247                let mut ref_pairs: Vec<(&str, &str)> = Vec::new();
248                for (facts, prep) in chunk.iter().zip(&prepared) {
249                    let file = facts.file.as_str();
250                    facts_table.insert(file, prep.encoded.as_slice())?;
251                    // content hash is deliberately NOT written here: it commits
252                    // last (commit_hashes), so a crash mid-derivation re-runs
253                    // these files as changed instead of freezing the damage.
254                    for (node, (blob, node_grams, node_words)) in
255                        facts.nodes.iter().zip(&prep.nodes)
256                    {
257                        let id = node.id.as_str();
258                        nodes.insert(id, blob.as_slice())?;
259                        let interned_existing = intern_rev.get(id)?.map(|g| g.value());
260                        let interned = match interned_existing {
261                            Some(existing) => existing,
262                            None => {
263                                let assigned = next_intern;
264                                next_intern += 1;
265                                intern.insert(assigned, id)?;
266                                intern_rev.insert(id, assigned)?;
267                                assigned
268                            }
269                        };
270                        name_pairs.push((node.name.as_str(), interned));
271                        for gram in node_grams {
272                            gram_pairs.push((gram.as_str(), interned));
273                        }
274                        for word in node_words {
275                            token_pairs.push((word.as_str(), interned));
276                        }
277                        delta.def_names.insert(node.name.clone());
278                    }
279                    for (edge, bytes) in facts.contains.iter().zip(&prep.edges) {
280                        out.insert(edge.src.as_str(), bytes.as_slice())?;
281                        inn.insert(edge.dst.as_str(), bytes.as_slice())?;
282                    }
283                    for r in &facts.references {
284                        ref_pairs.push((r.name.as_str(), file));
285                    }
286                    for bytes in &prep.imports {
287                        imports.insert(file, bytes.as_slice())?;
288                    }
289                }
290                name_pairs.sort_unstable();
291                for (name, interned) in name_pairs {
292                    name_nodes.insert(name, interned)?;
293                }
294                gram_pairs.sort_unstable();
295                for (gram, interned) in gram_pairs {
296                    grams.insert(gram, interned)?;
297                }
298                token_pairs.sort_unstable();
299                for (word, interned) in token_pairs {
300                    tokens.insert(word, interned)?;
301                }
302                ref_pairs.sort_unstable();
303                for (name, file) in ref_pairs {
304                    name_refs.insert(name, file)?;
305                }
306            }
307            meta.insert("intern_next", next_intern)?;
308
309            // Persist the delta (merged with any crash residue) atomically
310            // with the derivation it describes.
311            let mut merged: PendingSets = match pending.get(PENDING_KEY)? {
312                Some(guard) => postcard::from_bytes(guard.value())?,
313                None => Default::default(),
314            };
315            merged.0.extend(delta.def_names.iter().cloned());
316            merged.1.extend(delta.dependent_files.iter().cloned());
317            pending.insert(PENDING_KEY, postcard::to_allocvec(&merged)?.as_slice())?;
318        }
319        txn.commit()?;
320        Ok(delta)
321    }
322
323    /// The crash-residue delta: the union of every [`Store::update_files`]
324    /// delta since the last [`Store::clear_pending_delta`]. Empty on a
325    /// cleanly finished build.
326    pub fn pending_delta(&self) -> Result<NameDelta, StoreError> {
327        let txn = self.db.begin_read()?;
328        let table = match txn.open_table(PENDING) {
329            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(NameDelta::default()),
330            other => other?,
331        };
332        let Some(guard) = table.get(PENDING_KEY)? else {
333            return Ok(NameDelta::default());
334        };
335        let (def_names, dependent_files): PendingSets = postcard::from_bytes(guard.value())?;
336        Ok(NameDelta {
337            def_names,
338            dependent_files,
339        })
340    }
341
342    /// Mark the current build's derivation fully resolved and stamped.
343    /// Call only after hash stamps commit; a crash before this leaves the
344    /// pending delta for the next build to replay (idempotent — replay
345    /// re-resolves files into the same edges).
346    pub fn clear_pending_delta(&self) -> Result<(), StoreError> {
347        let residue = self.pending_delta()?;
348        // Clean builds stay write-free: no residue, no write transaction.
349        if residue.def_names.is_empty() && residue.dependent_files.is_empty() {
350            return Ok(());
351        }
352        let txn = self.db.begin_write()?;
353        {
354            let mut table = txn.open_table(PENDING)?;
355            table.remove(PENDING_KEY)?;
356        }
357        txn.commit()?;
358        Ok(())
359    }
360
361    /// Mark files fully derived by recording their content hashes. Call
362    /// only after every derived table (edges, unresolved) is consistent.
363    /// Stores a bare hash (no stat stamp), so the next scan re-hashes
364    /// these files once; the build path uses [`Store::commit_stamps`].
365    pub fn commit_hashes(&self, changed: &[FileFacts]) -> Result<(), StoreError> {
366        if changed.is_empty() {
367            return Ok(());
368        }
369        let txn = self.db.begin_write()?;
370        {
371            let mut hash_table = txn.open_table(FILE_HASH)?;
372            for facts in changed {
373                hash_table.insert(facts.file.as_str(), facts.content_hash.as_str())?;
374            }
375        }
376        txn.commit()?;
377        Ok(())
378    }
379
380    /// [`Store::commit_hashes`] with the stat identity attached: the scan
381    /// reuses each stored hash while (mtime, len) still match. Also the
382    /// stamp-refresh path for touched-but-unchanged files. Empty input
383    /// opens no write transaction (the clean-build no-op path).
384    pub fn commit_stamps(&self, rows: &[(String, crate::FileStamp)]) -> Result<(), StoreError> {
385        if rows.is_empty() {
386            return Ok(());
387        }
388        let txn = self.db.begin_write()?;
389        {
390            let mut hash_table = txn.open_table(FILE_HASH)?;
391            for (file, stamp) in rows {
392                hash_table.insert(file.as_str(), stamp.encode().as_str())?;
393            }
394        }
395        txn.commit()?;
396        Ok(())
397    }
398
399    /// Files containing references with any of these names — the set an
400    /// update invalidates beyond the changed files themselves.
401    pub fn ref_files(&self, names: &BTreeSet<String>) -> Result<BTreeSet<String>, StoreError> {
402        let txn = self.db.begin_read()?;
403        let table = txn.open_multimap_table(NAME_REFS)?;
404        let mut files = BTreeSet::new();
405        for name in names {
406            for guard in table.get(name.as_str())? {
407                files.insert(guard?.value().to_string());
408            }
409        }
410        Ok(files)
411    }
412
413    /// Read-only lookahead for [`Store::apply_resolution`]: the dst files
414    /// of Dynamic edges whose src node lives in one of these files. Those
415    /// files' trait-impl facts must join the re-resolution set or their
416    /// fan-out edges would be silently lost (dynamic edges are src-owned
417    /// like every resolution edge, but derived from dst-file facts).
418    pub fn dynamic_edge_dst_files(
419        &self,
420        files: &BTreeSet<String>,
421    ) -> Result<BTreeSet<String>, StoreError> {
422        let mut dynamic_dst_files = BTreeSet::new();
423        let txn = self.db.begin_read()?;
424        let facts_table = txn.open_table(FILE_FACTS)?;
425        let out = txn.open_multimap_table(OUT_EDGES)?;
426        for file in files {
427            let Some(facts): Option<FileFacts> = facts_table
428                .get(file.as_str())?
429                .map(|g| decode_facts(g.value()))
430                .transpose()?
431            else {
432                continue;
433            };
434            for node in &facts.nodes {
435                for guard in out.get(node.id.as_str())? {
436                    let edge: Edge = postcard::from_bytes(guard?.value())?;
437                    if edge.evidence == Evidence::Dynamic {
438                        dynamic_dst_files.insert(file_of_id(edge.dst.as_str()).to_string());
439                    }
440                }
441            }
442        }
443        Ok(dynamic_dst_files)
444    }
445
446    /// Commit one resolution pass atomically: drop non-structural
447    /// (resolution) edges whose src node lives in a `teardown` file,
448    /// insert the re-derived `edges` (both directions), and replace the
449    /// unresolved set for `unresolved_files`. One transaction — a crash
450    /// leaves either the old resolution state or the new one, never a
451    /// torn-down middle.
452    pub fn apply_resolution(
453        &self,
454        teardown: &BTreeSet<String>,
455        edges: &[Edge],
456        unresolved_files: &BTreeSet<String>,
457        unresolved: &[Reference],
458    ) -> Result<(), StoreError> {
459        let txn = self.db.begin_write()?;
460        {
461            let facts_table = txn.open_table(FILE_FACTS)?;
462            let mut out = txn.open_multimap_table(OUT_EDGES)?;
463            let mut inn = txn.open_multimap_table(IN_EDGES)?;
464            for file in teardown {
465                let Some(facts): Option<FileFacts> = facts_table
466                    .get(file.as_str())?
467                    .map(|g| decode_facts(g.value()))
468                    .transpose()?
469                else {
470                    continue;
471                };
472                for node in &facts.nodes {
473                    let bytes_list = collect_values(out.get(node.id.as_str())?)?;
474                    for bytes in bytes_list {
475                        let edge: Edge = postcard::from_bytes(&bytes)?;
476                        if edge.evidence != Evidence::Structural {
477                            out.remove(node.id.as_str(), bytes.as_slice())?;
478                            inn.remove(edge.dst.as_str(), bytes.as_slice())?;
479                        }
480                    }
481                }
482            }
483            for edge in representative_sites(edges) {
484                let bytes = postcard::to_allocvec(edge)?;
485                out.insert(edge.src.as_str(), bytes.as_slice())?;
486                inn.insert(edge.dst.as_str(), bytes.as_slice())?;
487            }
488            let mut table = txn.open_multimap_table(UNRESOLVED)?;
489            for file in unresolved_files {
490                table.remove_all(file.as_str())?;
491            }
492            for r in unresolved {
493                table.insert(r.file.as_str(), postcard::to_allocvec(r)?.as_slice())?;
494            }
495        }
496        txn.commit()?;
497        Ok(())
498    }
499
500    /// Insert resolution edges (both directions).
501    pub fn insert_edges(&self, edges: &[Edge]) -> Result<(), StoreError> {
502        let txn = self.db.begin_write()?;
503        {
504            let mut out = txn.open_multimap_table(OUT_EDGES)?;
505            let mut inn = txn.open_multimap_table(IN_EDGES)?;
506            for edge in representative_sites(edges) {
507                let bytes = postcard::to_allocvec(edge)?;
508                out.insert(edge.src.as_str(), bytes.as_slice())?;
509                inn.insert(edge.dst.as_str(), bytes.as_slice())?;
510            }
511        }
512        txn.commit()?;
513        Ok(())
514    }
515}
516
517/// One edge per identity: several call sites binding the same
518/// (src, dst, relation, evidence) keep a single representative site (the
519/// smallest — deterministic), so `site` never multiplies edge cardinality.
520/// The multimap's byte-identical dedup handles exact repeats; this handles
521/// same-identity edges whose sites differ.
522fn representative_sites(edges: &[Edge]) -> Vec<&Edge> {
523    let mut ordered: Vec<&Edge> = edges.iter().collect();
524    // Edge's derived Ord puts `site` last, so identity groups are adjacent
525    // and the smallest site sorts first within each group.
526    ordered.sort();
527    ordered.dedup_by(|a, b| a.identity() == b.identity());
528    ordered
529}
530
531const PENDING_KEY: &str = "delta";
532
533fn collect_values(
534    values: redb::MultimapValue<'_, &'static [u8]>,
535) -> Result<Vec<Vec<u8>>, StoreError> {
536    let mut out = Vec::new();
537    for guard in values {
538        out.push(guard?.value().to_vec());
539    }
540    Ok(out)
541}