1mod layout;
4
5use async_trait::async_trait;
6use gonzalo_core::{
7 BlobStore, ContentHash, CoreError, DeleteResult, KeyPrefix, PutResult, Record, RecordKey,
8 Result, Revision, Store, store::Conflict,
9};
10use rustix::fs::{FlockOperation, flock};
11use std::io::{self, Write};
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14use tokio::io::AsyncWriteExt;
15
16pub struct FsStore {
18 root: PathBuf,
19}
20
21impl FsStore {
22 pub fn new(root: impl Into<PathBuf>) -> Self {
23 Self { root: root.into() }
24 }
25
26 async fn read_record(&self, key: &RecordKey) -> Result<Option<Record>> {
27 let path = layout::record_path(&self.root, key);
28 match tokio::fs::read(&path).await {
29 Ok(bytes) => {
30 let rec: Record =
31 serde_json::from_slice(&bytes).map_err(|e| CoreError::Serde(e.to_string()))?;
32 Ok(Some(rec))
33 }
34 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
35 Err(e) => Err(CoreError::Backend(e.to_string())),
36 }
37 }
38}
39
40#[async_trait]
41impl Store for FsStore {
42 async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
43 self.read_record(key).await
44 }
45
46 async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
47 let root = self.root.clone();
54 tokio::task::spawn_blocking(move || put_locked(&root, record, expected))
55 .await
56 .map_err(|e| CoreError::Backend(format!("put task panicked: {e}")))?
57 }
58
59 async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
60 let mut out = Vec::new();
61 collect_keys(&self.root, prefix, &mut out).await?;
62 Ok(out)
63 }
64
65 async fn delete(&self, key: &RecordKey, expected: Option<Revision>) -> Result<DeleteResult> {
66 let root = self.root.clone();
70 let key = key.clone();
71 tokio::task::spawn_blocking(move || delete_locked(&root, &key, expected))
72 .await
73 .map_err(|e| CoreError::Backend(format!("delete task panicked: {e}")))?
74 }
75}
76
77static BLOB_TMP_NONCE: AtomicU64 = AtomicU64::new(0);
80
81#[async_trait]
82impl BlobStore for FsStore {
83 async fn put_blob(&self, content: &[u8]) -> Result<ContentHash> {
84 let hash = ContentHash::of(content);
85 let path = layout::blob_path(&self.root, &hash);
86
87 if tokio::fs::try_exists(&path)
90 .await
91 .map_err(|e| CoreError::Backend(e.to_string()))?
92 {
93 return Ok(hash);
94 }
95 if let Some(parent) = path.parent() {
96 tokio::fs::create_dir_all(parent)
97 .await
98 .map_err(|e| CoreError::Backend(e.to_string()))?;
99 }
100
101 let nonce = BLOB_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
105 let tmp = path.with_extension(format!("tmp.{}.{nonce}", std::process::id()));
106 let mut f = tokio::fs::File::create(&tmp)
113 .await
114 .map_err(|e| CoreError::Backend(e.to_string()))?;
115 f.write_all(content)
116 .await
117 .map_err(|e| CoreError::Backend(e.to_string()))?;
118 f.sync_all()
119 .await
120 .map_err(|e| CoreError::Backend(e.to_string()))?;
121 drop(f);
122 tokio::fs::rename(&tmp, &path)
123 .await
124 .map_err(|e| CoreError::Backend(e.to_string()))?;
125 if let Some(parent) = path.parent() {
126 let parent = parent.to_path_buf();
127 tokio::task::spawn_blocking(move || fsync_dir(&parent))
128 .await
129 .map_err(|e| CoreError::Backend(format!("fsync task panicked: {e}")))?
130 .map_err(|e| CoreError::Backend(e.to_string()))?;
131 }
132 Ok(hash)
133 }
134
135 async fn get_blob(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
136 let path = layout::blob_path(&self.root, hash);
137 match tokio::fs::read(&path).await {
138 Ok(bytes) => Ok(Some(bytes)),
139 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
140 Err(e) => Err(CoreError::Backend(e.to_string())),
141 }
142 }
143
144 async fn list_blobs(&self) -> Result<Vec<ContentHash>> {
145 let dir = layout::blobs_dir(&self.root);
146 let mut entries = match tokio::fs::read_dir(&dir).await {
147 Ok(rd) => rd,
148 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
150 Err(e) => return Err(CoreError::Backend(e.to_string())),
151 };
152 let mut out = Vec::new();
153 while let Some(entry) = entries
154 .next_entry()
155 .await
156 .map_err(|e| CoreError::Backend(e.to_string()))?
157 {
158 let name = entry.file_name().to_string_lossy().to_string();
159 if is_blob_hash(&name) {
164 out.push(ContentHash(name));
165 }
166 }
167 Ok(out)
168 }
169
170 async fn delete_blob(&self, hash: &ContentHash) -> Result<()> {
171 let path = layout::blob_path(&self.root, hash);
172 match tokio::fs::remove_file(&path).await {
173 Ok(()) => Ok(()),
174 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
176 Err(e) => Err(CoreError::Backend(e.to_string())),
177 }
178 }
179}
180
181fn is_blob_hash(name: &str) -> bool {
184 name.len() == 64
185 && name
186 .bytes()
187 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
188}
189
190fn put_locked(root: &Path, record: Record, expected: Option<Revision>) -> Result<PutResult> {
198 let path = layout::record_path(root, &record.key);
199 if let Some(parent) = path.parent() {
200 std::fs::create_dir_all(parent).map_err(|e| CoreError::Backend(e.to_string()))?;
201 }
202
203 let lock_path = path.with_extension("json.lock");
205 let lock = std::fs::OpenOptions::new()
206 .create(true)
207 .truncate(false)
208 .write(true)
209 .open(&lock_path)
210 .map_err(|e| CoreError::Backend(e.to_string()))?;
211 flock(&lock, FlockOperation::LockExclusive).map_err(|e| CoreError::Backend(e.to_string()))?;
212
213 let current = match std::fs::read(&path) {
215 Ok(bytes) => Some(
216 serde_json::from_slice::<Record>(&bytes)
217 .map_err(|e| CoreError::Serde(e.to_string()))?,
218 ),
219 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
220 Err(e) => return Err(CoreError::Backend(e.to_string())),
221 };
222 let current_rev = current.as_ref().map(|r| r.revision.clone());
223 if current_rev != expected {
224 if let Some(current) = current {
225 return Ok(PutResult::Conflict(Box::new(Conflict {
226 key: record.key.clone(),
227 expected,
228 current,
229 })));
230 }
231 return Err(CoreError::NotFound(record.key.clone()));
233 }
234
235 let bytes = serde_json::to_vec_pretty(&record).map_err(|e| CoreError::Serde(e.to_string()))?;
236 let tmp = path.with_extension("json.tmp");
243 let mut f = std::fs::File::create(&tmp).map_err(|e| CoreError::Backend(e.to_string()))?;
244 f.write_all(&bytes)
245 .map_err(|e| CoreError::Backend(e.to_string()))?;
246 f.sync_all()
247 .map_err(|e| CoreError::Backend(e.to_string()))?;
248 drop(f);
249 std::fs::rename(&tmp, &path).map_err(|e| CoreError::Backend(e.to_string()))?;
250 if let Some(parent) = path.parent() {
251 fsync_dir(parent).map_err(|e| CoreError::Backend(e.to_string()))?;
252 }
253 Ok(PutResult::Committed(record.revision))
254}
255
256fn delete_locked(root: &Path, key: &RecordKey, expected: Option<Revision>) -> Result<DeleteResult> {
266 let path = layout::record_path(root, key);
267 if let Some(parent) = path.parent() {
268 std::fs::create_dir_all(parent).map_err(|e| CoreError::Backend(e.to_string()))?;
269 }
270
271 let lock_path = path.with_extension("json.lock");
273 let lock = std::fs::OpenOptions::new()
274 .create(true)
275 .truncate(false)
276 .write(true)
277 .open(&lock_path)
278 .map_err(|e| CoreError::Backend(e.to_string()))?;
279 flock(&lock, FlockOperation::LockExclusive).map_err(|e| CoreError::Backend(e.to_string()))?;
280
281 let current = match std::fs::read(&path) {
283 Ok(bytes) => Some(
284 serde_json::from_slice::<Record>(&bytes)
285 .map_err(|e| CoreError::Serde(e.to_string()))?,
286 ),
287 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
288 Err(e) => return Err(CoreError::Backend(e.to_string())),
289 };
290
291 match (current, &expected) {
292 (None, _) => Ok(DeleteResult::Deleted),
295 (Some(cur), exp) if exp.is_none() || exp.as_ref() == Some(&cur.revision) => {
297 match std::fs::remove_file(&path) {
298 Ok(()) => {}
299 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
301 Err(e) => return Err(CoreError::Backend(e.to_string())),
302 }
303 if let Some(parent) = path.parent() {
304 fsync_dir(parent).map_err(|e| CoreError::Backend(e.to_string()))?;
305 }
306 Ok(DeleteResult::Deleted)
307 }
308 (Some(cur), _) => Ok(DeleteResult::Conflict(Box::new(Conflict {
310 key: key.clone(),
311 expected,
312 current: cur,
313 }))),
314 }
315}
316
317fn fsync_dir(path: &Path) -> io::Result<()> {
324 let dir = std::fs::File::open(path)?;
325 match dir.sync_all() {
326 Ok(()) => Ok(()),
327 Err(e) if e.kind() == io::ErrorKind::InvalidInput => Ok(()),
328 Err(e) => Err(e),
329 }
330}
331
332async fn collect_keys(
334 root: &std::path::Path,
335 prefix: &KeyPrefix,
336 out: &mut Vec<RecordKey>,
337) -> Result<()> {
338 let mut namespaces = match tokio::fs::read_dir(root).await {
339 Ok(rd) => rd,
340 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
341 Err(e) => return Err(CoreError::Backend(e.to_string())),
342 };
343 while let Some(ns) = namespaces
344 .next_entry()
345 .await
346 .map_err(|e| CoreError::Backend(e.to_string()))?
347 {
348 if ns.file_type().await.map(|ft| !ft.is_dir()).unwrap_or(true) {
349 continue;
350 }
351 let ns_name = ns.file_name().to_string_lossy().to_string();
352 let mut cols = tokio::fs::read_dir(ns.path())
353 .await
354 .map_err(|e| CoreError::Backend(e.to_string()))?;
355 while let Some(col) = cols
356 .next_entry()
357 .await
358 .map_err(|e| CoreError::Backend(e.to_string()))?
359 {
360 if col.file_type().await.map(|ft| !ft.is_dir()).unwrap_or(true) {
361 continue;
362 }
363 let col_name = col.file_name().to_string_lossy().to_string();
364 let mut files = tokio::fs::read_dir(col.path())
365 .await
366 .map_err(|e| CoreError::Backend(e.to_string()))?;
367 while let Some(f) = files
368 .next_entry()
369 .await
370 .map_err(|e| CoreError::Backend(e.to_string()))?
371 {
372 let fname = f.file_name().to_string_lossy().to_string();
373 if let Some(id) = fname.strip_suffix(".json") {
374 let key = RecordKey::new(
377 gonzalo_core::decode_segment(&ns_name),
378 gonzalo_core::decode_segment(&col_name),
379 gonzalo_core::decode_segment(id),
380 );
381 if prefix.matches(&key) {
382 out.push(key);
383 }
384 }
385 }
386 }
387 }
388 Ok(())
389}