Skip to main content

rto_graph/
sync.rs

1//! The incremental, content-addressed sync engine.
2//!
3//! `sync` brings a [`Store`] into agreement with the repository's `HEAD` tree.
4//! Extraction is the expensive part and is content-addressed by blob id, so only
5//! blobs whose content changed are re-extracted; the rest load from the
6//! [`ObjectCache`]. If the tree id is unchanged since the last sync, it is a
7//! no-op. The graph itself is reassembled from the (cached) per-blob fact sets
8//! and rebuilt in a single transaction — a deliberately simple DB-write model
9//! for this stage; incremental DB updates can come later.
10
11use crate::cache::{CacheError, ObjectCache};
12use crate::extract::Extractor;
13use crate::git::{GitError, Repo};
14use crate::store::StoreError;
15use crate::{FactSet, Store};
16
17/// Errors raised while syncing.
18#[derive(Debug, thiserror::Error)]
19pub enum SyncError {
20    /// A store operation failed.
21    #[error(transparent)]
22    Store(#[from] StoreError),
23    /// A cache operation failed.
24    #[error(transparent)]
25    Cache(#[from] CacheError),
26    /// A git operation failed.
27    #[error(transparent)]
28    Git(#[from] GitError),
29}
30
31/// A summary of the work a [`sync`] performed.
32#[derive(Debug, Clone, Default, serde::Serialize)]
33pub struct SyncReport {
34    /// Hex id of the synced `HEAD` tree.
35    pub tree: String,
36    /// Whether the tree was unchanged and nothing was done.
37    pub no_op: bool,
38    /// Total blobs in the tree.
39    pub blobs_total: usize,
40    /// Blobs that were extracted (cache misses).
41    pub blobs_extracted: usize,
42    /// Blobs served from the cache (cache hits).
43    pub blobs_cached: usize,
44    /// Nodes in the store after syncing.
45    pub nodes: u64,
46    /// Edges in the store after syncing.
47    pub edges: u64,
48}
49
50/// Sync `store` to the repository's `HEAD` tree, extracting changed blobs with
51/// `extractor` and caching results in `cache`.
52///
53/// # Errors
54/// Returns a [`SyncError`] if git access, extraction caching, or the store
55/// rebuild fails.
56pub fn sync(
57    store: &mut Store,
58    repo: &Repo,
59    cache: &ObjectCache,
60    extractor: &dyn Extractor,
61) -> Result<SyncReport, SyncError> {
62    let tree = repo.head_tree_id()?;
63
64    if store.sync_state()?.as_deref() == Some(tree.as_str()) {
65        return Ok(SyncReport {
66            no_op: true,
67            nodes: store.node_count()?,
68            edges: store.edge_count()?,
69            tree,
70            ..SyncReport::default()
71        });
72    }
73
74    let blobs = repo.walk_blobs()?;
75    let mut assembled = FactSet::new();
76    let mut extracted = 0usize;
77    let mut cached = 0usize;
78
79    for blob in &blobs {
80        // Extraction is a pure function of (path, blob bytes), not blob id
81        // alone: node keys are path-scoped (e.g. `file:<path>`), so the same
82        // blob content at two different paths yields different facts. Key the
83        // cache by (path, oid) so duplicate-content files (e.g. empty files,
84        // which git dedupes to one oid) never collide, while the same path+oid
85        // in another branch/worktree still hits.
86        let key = cache_key(&blob.path, &blob.oid);
87        let facts = if let Some(facts) = cache.get(&key)? {
88            cached += 1;
89            facts
90        } else {
91            let bytes = repo.read_blob(&blob.oid)?;
92            let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
93            cache.put(&key, &facts)?;
94            extracted += 1;
95            facts
96        };
97        let FactSet { nodes, edges } = facts;
98        assembled.nodes.extend(nodes);
99        assembled.edges.extend(edges);
100    }
101
102    store.rebuild(&assembled, &tree)?;
103
104    Ok(SyncReport {
105        no_op: false,
106        blobs_total: blobs.len(),
107        blobs_extracted: extracted,
108        blobs_cached: cached,
109        nodes: store.node_count()?,
110        edges: store.edge_count()?,
111        tree,
112    })
113}
114
115/// Content-addressed cache key for a blob at a given path: the blob oid (kept
116/// as the leading, well-distributed shard) suffixed with a stable 64-bit hash
117/// of the path. Sharing across branches/worktrees is preserved (same path+oid →
118/// same key) while duplicate content at distinct paths stays distinct.
119fn cache_key(path: &str, oid: &str) -> String {
120    format!("{oid}-{:016x}", fnv1a64(path.as_bytes()))
121}
122
123/// FNV-1a (64-bit). Dependency-free and deterministic; used only to derive
124/// cache filenames, so it needs no cryptographic properties.
125fn fnv1a64(bytes: &[u8]) -> u64 {
126    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
127    for &b in bytes {
128        hash ^= u64::from(b);
129        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
130    }
131    hash
132}
133
134#[cfg(test)]
135mod tests {
136    use super::cache_key;
137
138    #[test]
139    fn cache_key_separates_paths_but_is_stable() {
140        let oid = "abc123";
141        // Same path + oid is stable across calls.
142        assert_eq!(cache_key("src/a.rs", oid), cache_key("src/a.rs", oid));
143        // Same blob content (oid) at two different paths must not collide.
144        assert_ne!(cache_key("src/a.rs", oid), cache_key("src/b.rs", oid));
145        // Different content at the same path differs too.
146        assert_ne!(cache_key("src/a.rs", "aaa"), cache_key("src/a.rs", "bbb"));
147        // Key stays sharded on the oid so the cache's 2-char shard is well spread.
148        assert!(cache_key("src/a.rs", oid).starts_with("abc123-"));
149    }
150}