Skip to main content

arete_server/snapshot/
store.rs

1//! Pluggable snapshot blob storage.
2//!
3//! The filesystem store (self-hosters, k8s PVCs) is always available. An
4//! `object_store`-backed implementation (S3/GCS/Azure) lives in
5//! [`super::object`] behind the `snapshot-object-store` cargo feature.
6
7use anyhow::{Context, Result};
8use async_trait::async_trait;
9use std::path::{Path, PathBuf};
10
11pub const SNAPSHOT_FILE_PREFIX: &str = "snapshot-";
12pub const SNAPSHOT_FILE_SUFFIX: &str = ".arsnap";
13
14/// Build the blob name for a snapshot. Zero-padded so lexicographic order
15/// equals chronological order, which `load_latest`/`prune` rely on.
16pub fn snapshot_name(created_at_epoch_ms: u64, resume_watermark: u64) -> String {
17    format!("{SNAPSHOT_FILE_PREFIX}{created_at_epoch_ms:015}-{resume_watermark:015}{SNAPSHOT_FILE_SUFFIX}")
18}
19
20#[async_trait]
21pub trait SnapshotStore: Send + Sync {
22    /// Atomically persist one snapshot blob under `name`.
23    async fn write(&self, name: &str, bytes: &[u8]) -> Result<()>;
24
25    /// Return the newest snapshot blob, or `None` if the store is empty.
26    async fn load_latest(&self) -> Result<Option<(String, Vec<u8>)>>;
27
28    /// Delete all but the newest `keep` snapshots. Returns how many were removed.
29    async fn prune(&self, keep: usize) -> Result<usize>;
30
31    /// Human-readable location for logs.
32    fn describe(&self) -> String;
33}
34
35/// Local-filesystem store. Writes go to a temp file in the same directory
36/// followed by a rename, so a crash mid-write never corrupts the latest
37/// snapshot.
38pub struct FsStore {
39    dir: PathBuf,
40}
41
42impl FsStore {
43    pub fn new(dir: impl Into<PathBuf>) -> Self {
44        Self { dir: dir.into() }
45    }
46
47    async fn snapshot_names(&self) -> Result<Vec<String>> {
48        let mut names = Vec::new();
49        let mut entries = match tokio::fs::read_dir(&self.dir).await {
50            Ok(entries) => entries,
51            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(names),
52            Err(err) => return Err(err).context("read snapshot directory"),
53        };
54        while let Some(entry) = entries.next_entry().await? {
55            let name = entry.file_name().to_string_lossy().into_owned();
56            if name.starts_with(SNAPSHOT_FILE_PREFIX) && name.ends_with(SNAPSHOT_FILE_SUFFIX) {
57                names.push(name);
58            }
59        }
60        // Newest first (names embed a zero-padded timestamp).
61        names.sort_by(|a, b| b.cmp(a));
62        Ok(names)
63    }
64}
65
66#[async_trait]
67impl SnapshotStore for FsStore {
68    async fn write(&self, name: &str, bytes: &[u8]) -> Result<()> {
69        tokio::fs::create_dir_all(&self.dir)
70            .await
71            .with_context(|| format!("create snapshot directory {}", self.dir.display()))?;
72        let tmp_path = self.dir.join(format!(".tmp-{name}"));
73        let final_path = self.dir.join(name);
74        tokio::fs::write(&tmp_path, bytes)
75            .await
76            .with_context(|| format!("write {}", tmp_path.display()))?;
77        tokio::fs::rename(&tmp_path, &final_path)
78            .await
79            .with_context(|| format!("rename into {}", final_path.display()))?;
80        Ok(())
81    }
82
83    async fn load_latest(&self) -> Result<Option<(String, Vec<u8>)>> {
84        let names = self.snapshot_names().await?;
85        let Some(name) = names.into_iter().next() else {
86            return Ok(None);
87        };
88        let path = self.dir.join(&name);
89        let bytes = tokio::fs::read(&path)
90            .await
91            .with_context(|| format!("read {}", path.display()))?;
92        Ok(Some((name, bytes)))
93    }
94
95    async fn prune(&self, keep: usize) -> Result<usize> {
96        let names = self.snapshot_names().await?;
97        let mut removed = 0;
98        for name in names.into_iter().skip(keep.max(1)) {
99            let path = self.dir.join(&name);
100            if let Err(err) = tokio::fs::remove_file(&path).await {
101                tracing::warn!(path = %path.display(), error = %err, "Failed to prune snapshot");
102            } else {
103                removed += 1;
104            }
105        }
106        Ok(removed)
107    }
108
109    fn describe(&self) -> String {
110        format!("file://{}", self.dir.display())
111    }
112}
113
114/// Build a store from a URL-ish string:
115/// - `file:///var/lib/arete/snapshots` or a plain path -> [`FsStore`]
116/// - `s3://` / `gs://` / `az://` -> [`super::object::ObjectSnapshotStore`],
117///   available behind the `snapshot-object-store` feature; rejected with a
118///   clear error when the feature is not compiled in.
119pub fn store_from_url(url: &str) -> Result<std::sync::Arc<dyn SnapshotStore>> {
120    let trimmed = url.trim();
121    if let Some(path) = trimmed.strip_prefix("file://") {
122        if path.is_empty() {
123            anyhow::bail!("snapshot URL {trimmed:?} has an empty path");
124        }
125        return Ok(std::sync::Arc::new(FsStore::new(Path::new(path))));
126    }
127    if trimmed.contains("://") {
128        #[cfg(feature = "snapshot-object-store")]
129        return super::object::from_url(trimmed);
130        #[cfg(not(feature = "snapshot-object-store"))]
131        {
132            let scheme = trimmed
133                .split_once("://")
134                .map(|(s, _)| s)
135                .unwrap_or_default();
136            anyhow::bail!(
137                "snapshot URL scheme {scheme:?} requires the `snapshot-object-store` feature, \
138                 which is not enabled in this build; use a file:// URL or a local path"
139            );
140        }
141    }
142    if trimmed.is_empty() {
143        anyhow::bail!("snapshot URL is empty");
144    }
145    Ok(std::sync::Arc::new(FsStore::new(Path::new(trimmed))))
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn temp_dir(tag: &str) -> PathBuf {
153        let dir =
154            std::env::temp_dir().join(format!("arete-snapshot-store-{tag}-{}", std::process::id()));
155        let _ = std::fs::remove_dir_all(&dir);
156        dir
157    }
158
159    #[tokio::test]
160    async fn write_load_latest_and_prune() {
161        let dir = temp_dir("basic");
162        let store = FsStore::new(&dir);
163
164        assert!(store.load_latest().await.unwrap().is_none());
165
166        for (ts, payload) in [(1u64, b"one".as_slice()), (2, b"two"), (3, b"three")] {
167            store
168                .write(&snapshot_name(ts, ts * 10), payload)
169                .await
170                .unwrap();
171        }
172
173        let (name, bytes) = store.load_latest().await.unwrap().unwrap();
174        assert_eq!(name, snapshot_name(3, 30));
175        assert_eq!(bytes, b"three");
176
177        let removed = store.prune(2).await.unwrap();
178        assert_eq!(removed, 1);
179        assert!(!dir.join(snapshot_name(1, 10)).exists());
180        assert!(dir.join(snapshot_name(2, 20)).exists());
181        assert!(dir.join(snapshot_name(3, 30)).exists());
182
183        let _ = std::fs::remove_dir_all(&dir);
184    }
185
186    #[tokio::test]
187    async fn ignores_foreign_files() {
188        let dir = temp_dir("foreign");
189        std::fs::create_dir_all(&dir).unwrap();
190        std::fs::write(dir.join("notes.txt"), b"hi").unwrap();
191        std::fs::write(dir.join(".tmp-snapshot-x.arsnap.partial"), b"junk").unwrap();
192
193        let store = FsStore::new(&dir);
194        assert!(store.load_latest().await.unwrap().is_none());
195
196        let _ = std::fs::remove_dir_all(&dir);
197    }
198
199    #[test]
200    fn store_from_url_variants() {
201        assert!(store_from_url("file:///tmp/snaps").is_ok());
202        assert!(store_from_url("/tmp/snaps").is_ok());
203        assert!(store_from_url("relative/snaps").is_ok());
204        // Cloud schemes only work when the object-store backend is compiled in.
205        #[cfg(not(feature = "snapshot-object-store"))]
206        assert!(store_from_url("s3://bucket/prefix").is_err());
207        assert!(store_from_url("ftp://bucket/prefix").is_err());
208        assert!(store_from_url("").is_err());
209    }
210}