Skip to main content

repolith_cache/
namespaced.rs

1//! `NamespacedCache` — decorator that prefixes every `ActionId` before
2//! it reaches the inner backend.
3//!
4//! This is the bridge between federation and a **shared** cache: two child
5//! stacks that both declare a node `api` would collide in a single Neo4j
6//! database; wrapping each child's handle in
7//! `NamespacedCache::new(inner, "parent-node::")` keeps their keys
8//! disjoint. Local per-stack `SQLite` caches (the federation default) do
9//! not need this — separate files cannot collide.
10
11use async_trait::async_trait;
12use repolith_core::cache::{Cache, Result};
13use repolith_core::types::{ActionId, BuildEvent, BuildRecord};
14
15/// Cache decorator that prefixes every id with a fixed namespace.
16pub struct NamespacedCache<C> {
17    inner: C,
18    prefix: String,
19}
20
21impl<C: Cache> NamespacedCache<C> {
22    /// Wrap `inner`, prefixing every id with `prefix` (e.g. `"stack-a::"`).
23    pub fn new(inner: C, prefix: impl Into<String>) -> Self {
24        Self {
25            inner,
26            prefix: prefix.into(),
27        }
28    }
29
30    fn prefixed(&self, id: &ActionId) -> ActionId {
31        ActionId(format!("{}{}", self.prefix, id.0))
32    }
33
34    /// Rewrite the event's id with the namespace prefix.
35    fn prefixed_event(&self, event: BuildEvent) -> BuildEvent {
36        match event {
37            BuildEvent::Success {
38                id,
39                input,
40                output,
41                ms,
42            } => BuildEvent::Success {
43                id: self.prefixed(&id),
44                input,
45                output,
46                ms,
47            },
48            BuildEvent::Failed {
49                id,
50                input,
51                error,
52                ms,
53            } => BuildEvent::Failed {
54                id: self.prefixed(&id),
55                input,
56                error,
57                ms,
58            },
59        }
60    }
61
62    /// Strip the prefix from an id coming back out of the inner backend so
63    /// callers see the same ids they stored.
64    fn stripped_event(&self, event: BuildEvent) -> BuildEvent {
65        let strip = |id: ActionId| {
66            ActionId(
67                id.0.strip_prefix(&self.prefix)
68                    .map_or_else(|| id.0.clone(), std::string::ToString::to_string),
69            )
70        };
71        match event {
72            BuildEvent::Success {
73                id,
74                input,
75                output,
76                ms,
77            } => BuildEvent::Success {
78                id: strip(id),
79                input,
80                output,
81                ms,
82            },
83            BuildEvent::Failed {
84                id,
85                input,
86                error,
87                ms,
88            } => BuildEvent::Failed {
89                id: strip(id),
90                input,
91                error,
92                ms,
93            },
94        }
95    }
96}
97
98#[async_trait]
99impl<C: Cache> Cache for NamespacedCache<C> {
100    async fn last_build(&self, id: &ActionId) -> Option<BuildEvent> {
101        let event = self.inner.last_build(&self.prefixed(id)).await?;
102        Some(self.stripped_event(event))
103    }
104
105    /// Forwarded explicitly rather than left to the trait's default body,
106    /// which would route through [`Cache::last_build`] and silently drop a
107    /// timestamp the inner backend actually has. This decorator is the
108    /// federation-over-shared-Neo4j path — exactly where knowing *when* a
109    /// sibling machine built something is the point.
110    async fn last_record(&self, id: &ActionId) -> Option<BuildRecord> {
111        let rec = self.inner.last_record(&self.prefixed(id)).await?;
112        Some(BuildRecord {
113            event: self.stripped_event(rec.event),
114            recorded_at: rec.recorded_at,
115        })
116    }
117
118    async fn record(&mut self, event: BuildEvent) -> Result<()> {
119        let event = self.prefixed_event(event);
120        self.inner.record(event).await
121    }
122
123    async fn record_batch(&mut self, events: Vec<BuildEvent>) -> Result<()> {
124        let events = events.into_iter().map(|e| self.prefixed_event(e)).collect();
125        self.inner.record_batch(events).await
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::SqliteCache;
133    use repolith_core::types::Sha256;
134
135    fn success(id: &str) -> BuildEvent {
136        BuildEvent::Success {
137            id: ActionId(id.to_string()),
138            input: Sha256([1; 32]),
139            output: Sha256([2; 32]),
140            ms: 5,
141        }
142    }
143
144    #[tokio::test]
145    async fn namespaces_are_disjoint_on_a_shared_backend() {
146        // Two namespaces over the SAME inner cache must not see each other.
147        let dir = tempfile::tempdir().unwrap();
148        let shared = SqliteCache::open(dir.path().join("c.db")).unwrap();
149        let mut a = NamespacedCache::new(shared.clone(), "stack-a::");
150        let mut b = NamespacedCache::new(shared, "stack-b::");
151        let id = ActionId("api::git-clone::0".to_string());
152
153        a.record(success(&id.0)).await.unwrap();
154        assert!(a.last_build(&id).await.is_some(), "a sees its own event");
155        assert!(
156            b.last_build(&id).await.is_none(),
157            "b must not see a's event"
158        );
159
160        b.record(success(&id.0)).await.unwrap();
161        assert!(b.last_build(&id).await.is_some());
162    }
163
164    /// The decorator must forward the timestamp, not fall back to the
165    /// trait's default body — which would compile fine, pass every other
166    /// test, and quietly report "never dated" for every federated action.
167    #[tokio::test]
168    async fn the_inner_timestamp_survives_the_decorator() {
169        let mut c = NamespacedCache::new(SqliteCache::in_memory().unwrap(), "ns::");
170        let id = ActionId("node::kind::0".to_string());
171        c.record(success(&id.0)).await.unwrap();
172
173        let rec = c.last_record(&id).await.expect("record readable");
174        assert!(
175            rec.recorded_at.is_some(),
176            "the inner SQLite backend dated this write; the decorator dropped it"
177        );
178        match rec.event {
179            BuildEvent::Success { id: got, .. } => {
180                assert_eq!(got, id, "the prefix must be stripped on this path too");
181            }
182            BuildEvent::Failed { .. } => panic!("expected Success"),
183        }
184    }
185
186    #[tokio::test]
187    async fn ids_roundtrip_unprefixed() {
188        let mut c = NamespacedCache::new(SqliteCache::in_memory().unwrap(), "ns::");
189        let id = ActionId("node::kind::0".to_string());
190        c.record(success(&id.0)).await.unwrap();
191        match c.last_build(&id).await.unwrap() {
192            BuildEvent::Success { id: got, .. } => assert_eq!(got, id, "prefix must be stripped"),
193            BuildEvent::Failed { .. } => panic!("expected Success"),
194        }
195    }
196}