arete_server/snapshot/
store.rs1use 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
14pub 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 async fn write(&self, name: &str, bytes: &[u8]) -> Result<()>;
24
25 async fn load_latest(&self) -> Result<Option<(String, Vec<u8>)>>;
27
28 async fn prune(&self, keep: usize) -> Result<usize>;
30
31 fn describe(&self) -> String;
33}
34
35pub 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 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
114pub 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 #[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}