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