Skip to main content

sui_dockerfile_node_cache_daemon/
store.rs

1//! The local L0 disk store — the injectable seam this crate's server
2//! logic reads/writes through. Never touched directly by tests; tests
3//! use [`MockLocalCacheStore`] the same way `sui-dockerfile-wrapper`'s
4//! tests use `MockCacheBackend`.
5
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use std::sync::Mutex;
9
10use async_trait::async_trait;
11
12use crate::protocol::CachedArtifact;
13use crate::DaemonError;
14
15/// Abstraction over the node-local content-addressed cache directory.
16/// Content hashes are opaque strings (the same `content_hash` a
17/// `DockerfileGraph` node already carries); this trait does not
18/// interpret them beyond using them as a lookup key.
19#[async_trait]
20pub trait LocalCacheStore: Send + Sync {
21    async fn get(&self, content_hash: &str) -> Result<Option<CachedArtifact>, DaemonError>;
22    async fn put(&self, content_hash: &str, artifact: &CachedArtifact) -> Result<(), DaemonError>;
23}
24
25/// Real disk-backed implementation. A `DaemonSet` deployment backs
26/// `root` with a `hostPath` volume so entries survive pod restarts —
27/// see the `sui-dockerfile-node-cache-daemon` Helm chart values.
28///
29/// Layout: `<root>/<first 2 hex chars of hash>/<hash>.json`, mirroring
30/// the classic two-level fan-out used by Nix store paths and most
31/// content-addressed caches, so a single directory never accumulates
32/// millions of entries.
33pub struct RealLocalCacheStore {
34    root: PathBuf,
35}
36
37impl RealLocalCacheStore {
38    #[must_use]
39    pub fn new(root: PathBuf) -> Self {
40        Self { root }
41    }
42
43    fn entry_path(&self, content_hash: &str) -> PathBuf {
44        let shard = if content_hash.len() >= 2 { &content_hash[..2] } else { "00" };
45        self.root.join(shard).join(format!("{content_hash}.json"))
46    }
47}
48
49#[async_trait]
50impl LocalCacheStore for RealLocalCacheStore {
51    async fn get(&self, content_hash: &str) -> Result<Option<CachedArtifact>, DaemonError> {
52        let path = self.entry_path(content_hash);
53        match tokio::fs::read(&path).await {
54            Ok(bytes) => {
55                let artifact: CachedArtifact =
56                    serde_json::from_slice(&bytes).map_err(|e| DaemonError::Protocol(e.to_string()))?;
57                Ok(Some(artifact))
58            }
59            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
60            Err(e) => Err(DaemonError::Io(e)),
61        }
62    }
63
64    async fn put(&self, content_hash: &str, artifact: &CachedArtifact) -> Result<(), DaemonError> {
65        let path = self.entry_path(content_hash);
66        if let Some(parent) = path.parent() {
67            tokio::fs::create_dir_all(parent).await?;
68        }
69        let body = serde_json::to_vec(artifact).map_err(|e| DaemonError::Protocol(e.to_string()))?;
70        // Write to a temp file then rename, so a crash mid-write never
71        // leaves a truncated/corrupt entry for a later `Get` to trip
72        // over — same atomic-publish discipline the rest of sui's
73        // storage backends use.
74        let tmp_path = path.with_extension("json.tmp");
75        tokio::fs::write(&tmp_path, &body).await?;
76        tokio::fs::rename(&tmp_path, &path).await?;
77        Ok(())
78    }
79}
80
81/// In-memory mock for unit tests. Never touches a filesystem.
82#[derive(Default)]
83pub struct MockLocalCacheStore {
84    entries: Mutex<BTreeMap<String, CachedArtifact>>,
85    /// Number of `get` calls observed — lets tests assert a second
86    /// `Get` for the same hash is served without re-touching anything.
87    pub get_calls: Mutex<usize>,
88}
89
90impl MockLocalCacheStore {
91    #[must_use]
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// # Panics
97    ///
98    /// Panics only if the internal mutex is poisoned (a prior panic
99    /// while holding the lock) — never in normal test use.
100    #[must_use]
101    pub fn with_entry(self, content_hash: &str, artifact: CachedArtifact) -> Self {
102        self.entries.lock().expect("mock mutex poisoned").insert(content_hash.to_string(), artifact);
103        self
104    }
105
106    /// Snapshot of hashes currently present — used by tests to assert
107    /// a remote-fetched entry was actually persisted locally.
108    ///
109    /// # Panics
110    ///
111    /// Panics only if the internal mutex is poisoned (a prior panic
112    /// while holding the lock) — never in normal test use.
113    #[must_use]
114    pub fn contains(&self, content_hash: &str) -> bool {
115        self.entries.lock().expect("mock mutex poisoned").contains_key(content_hash)
116    }
117}
118
119#[async_trait]
120impl LocalCacheStore for MockLocalCacheStore {
121    async fn get(&self, content_hash: &str) -> Result<Option<CachedArtifact>, DaemonError> {
122        *self.get_calls.lock().expect("mock mutex poisoned") += 1;
123        Ok(self.entries.lock().expect("mock mutex poisoned").get(content_hash).cloned())
124    }
125
126    async fn put(&self, content_hash: &str, artifact: &CachedArtifact) -> Result<(), DaemonError> {
127        self.entries
128            .lock()
129            .expect("mock mutex poisoned")
130            .insert(content_hash.to_string(), artifact.clone());
131        Ok(())
132    }
133}
134
135/// Best-effort helper: ensure a root directory exists before serving.
136/// Kept as a free function (not folded into `RealLocalCacheStore::new`)
137/// so construction stays infallible and synchronous; callers await
138/// this once at daemon startup.
139///
140/// # Errors
141///
142/// Propagates any I/O failure creating the directory.
143pub async fn ensure_root_exists(root: &Path) -> Result<(), DaemonError> {
144    tokio::fs::create_dir_all(root).await?;
145    Ok(())
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[tokio::test]
153    async fn real_store_put_then_get_round_trips() {
154        let dir = tempfile::tempdir().unwrap();
155        let store = RealLocalCacheStore::new(dir.path().to_path_buf());
156        let artifact = CachedArtifact { image_ref: "example/image:v1".to_string() };
157        store.put("deadbeef", &artifact).await.unwrap();
158        let got = store.get("deadbeef").await.unwrap();
159        assert_eq!(got, Some(artifact));
160    }
161
162    #[tokio::test]
163    async fn real_store_miss_returns_none_not_an_error() {
164        let dir = tempfile::tempdir().unwrap();
165        let store = RealLocalCacheStore::new(dir.path().to_path_buf());
166        let got = store.get("nope").await.unwrap();
167        assert_eq!(got, None);
168    }
169
170    #[tokio::test]
171    async fn mock_store_get_calls_are_counted() {
172        let store = MockLocalCacheStore::new();
173        store.get("x").await.unwrap();
174        store.get("x").await.unwrap();
175        assert_eq!(*store.get_calls.lock().unwrap(), 2);
176    }
177}