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};
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    async fn record(&mut self, event: BuildEvent) -> Result<()> {
106        let event = self.prefixed_event(event);
107        self.inner.record(event).await
108    }
109
110    async fn record_batch(&mut self, events: Vec<BuildEvent>) -> Result<()> {
111        let events = events.into_iter().map(|e| self.prefixed_event(e)).collect();
112        self.inner.record_batch(events).await
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::SqliteCache;
120    use repolith_core::types::Sha256;
121
122    fn success(id: &str) -> BuildEvent {
123        BuildEvent::Success {
124            id: ActionId(id.to_string()),
125            input: Sha256([1; 32]),
126            output: Sha256([2; 32]),
127            ms: 5,
128        }
129    }
130
131    #[tokio::test]
132    async fn namespaces_are_disjoint_on_a_shared_backend() {
133        // Two namespaces over the SAME inner cache must not see each other.
134        let dir = tempfile::tempdir().unwrap();
135        let shared = SqliteCache::open(dir.path().join("c.db")).unwrap();
136        let mut a = NamespacedCache::new(shared.clone(), "stack-a::");
137        let mut b = NamespacedCache::new(shared, "stack-b::");
138        let id = ActionId("api::git-clone::0".to_string());
139
140        a.record(success(&id.0)).await.unwrap();
141        assert!(a.last_build(&id).await.is_some(), "a sees its own event");
142        assert!(
143            b.last_build(&id).await.is_none(),
144            "b must not see a's event"
145        );
146
147        b.record(success(&id.0)).await.unwrap();
148        assert!(b.last_build(&id).await.is_some());
149    }
150
151    #[tokio::test]
152    async fn ids_roundtrip_unprefixed() {
153        let mut c = NamespacedCache::new(SqliteCache::in_memory().unwrap(), "ns::");
154        let id = ActionId("node::kind::0".to_string());
155        c.record(success(&id.0)).await.unwrap();
156        match c.last_build(&id).await.unwrap() {
157            BuildEvent::Success { id: got, .. } => assert_eq!(got, id, "prefix must be stripped"),
158            BuildEvent::Failed { .. } => panic!("expected Success"),
159        }
160    }
161}