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}