Skip to main content

gonzalo_store_fs/
lib.rs

1//! Filesystem storage substrate for gonzalo.
2
3mod 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
16/// A `Store` backed by JSON files under a root directory.
17pub 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        // The OCC read-check-write-rename is a critical section: without
48        // serialization a concurrent writer can commit between our read and our
49        // rename, silently losing an update. Hold a per-record advisory file
50        // lock (flock) across the whole section so writers — in this process or
51        // another — serialize. flock is blocking, so run it on a blocking
52        // thread rather than stalling the async runtime.
53        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        // Mirror `put`'s critical section: hold the per-record flock so the
67        // read→check→remove is atomic against a concurrent writer. Blocking, so
68        // run it on a blocking thread rather than stalling the async runtime.
69        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
77/// Process-unique nonce for blob temp files, so concurrent writers never share
78/// a temp path (see `put_blob`).
79static 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        // Write-if-absent: identical content hashes to the same path, so an
88        // existing blob is already exactly these bytes — nothing to do.
89        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        // Atomic publish: write a process-unique temp, then rename into place.
102        // Content-addressing makes a same-content race benign (byte-identical),
103        // and the unique temp keeps two racing writers from clobbering one temp.
104        let nonce = BLOB_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
105        let tmp = path.with_extension(format!("tmp.{}.{nonce}", std::process::id()));
106        // Durable publish: write the temp file and `sync_all` it so its bytes
107        // reach disk BEFORE the rename, then fsync the parent directory AFTER
108        // the rename so the new directory entry survives a crash too. `rename`
109        // is atomic against concurrent readers but not against power loss — on
110        // ext4 delayed allocation a crash just after a reported success can
111        // otherwise leave a zero-length or truncated blob.
112        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            // No blobs dir yet == no blobs.
149            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            // A committed blob's filename is exactly its blake3 hex hash. In-flight
160            // temp files (`<hash>.tmp.<pid>.<nonce>`) and any stray files carry a
161            // `.` and are skipped, so a concurrent `put_blob` is never mistaken for
162            // a collectable blob.
163            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            // Idempotent: an already-absent blob is a successful no-op.
175            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
176            Err(e) => Err(CoreError::Backend(e.to_string())),
177        }
178    }
179}
180
181/// Whether `name` is a committed blob's filename: blake3 hex, `[0-9a-f]{64}`.
182/// Excludes in-flight temp files and any stray non-blob entries.
183fn 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
190/// Perform the conditional `put` under a per-record advisory lock. Blocking by
191/// design (held across read→check→write→rename); call from `spawn_blocking`.
192///
193/// The lock is a sibling `<id>.json.lock` file held exclusively via `flock`,
194/// released when `lock` drops. It guards only writers — `get`/`list` stay
195/// lock-free — which is sufficient: the lost update is a write/write race, and
196/// the final `rename` is atomic so readers never observe a torn file.
197fn 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    // Acquire the exclusive lock; it lives until `lock` drops at function end.
204    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    // Critical section: revision check and write are now serialized per record.
214    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        // expected referenced a revision but nothing exists: treat as conflict
232        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    // Durable atomic write: write the temp file and `sync_all` it so its bytes
237    // reach disk BEFORE the rename, then fsync the parent directory AFTER the
238    // rename so the new directory entry survives a crash too. `rename` is atomic
239    // against concurrent readers but not against power loss — on ext4 delayed
240    // allocation a crash just after a reported Committed can otherwise leave a
241    // zero-length or truncated record.
242    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
256/// Perform the conditional `delete` under the same per-record advisory lock
257/// `put_locked` uses, so the read→check→remove is atomic against a concurrent
258/// writer. Blocking by design; call from `spawn_blocking`.
259///
260/// `expected == None` removes the record if present (idempotent no-op if
261/// absent). `expected == Some(rev)` removes only if the current revision matches;
262/// a mismatch is a `Conflict`, and an already-absent key is an idempotent
263/// `Deleted` (the revision is already gone — nothing to conflict on). We leave
264/// the sibling `.lock` file in place (it is reused by the next writer).
265fn 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    // Acquire the exclusive lock; it lives until `lock` drops at function end.
272    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    // Critical section: revision check and removal are now serialized per record.
282    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        // Absent: nothing to remove. Idempotent `Deleted` regardless of
293        // `expected` — the revision the caller named is already gone.
294        (None, _) => Ok(DeleteResult::Deleted),
295        // Unconditional, or the expected revision matches: remove the record.
296        (Some(cur), exp) if exp.is_none() || exp.as_ref() == Some(&cur.revision) => {
297            match std::fs::remove_file(&path) {
298                Ok(()) => {}
299                // A concurrent remover won under the lock hand-off — still absent.
300                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        // Present but the expected revision differs: surface a Conflict.
309        (Some(cur), _) => Ok(DeleteResult::Conflict(Box::new(Conflict {
310            key: key.clone(),
311            expected,
312            current: cur,
313        }))),
314    }
315}
316
317/// Best-effort fsync of the directory `path`, making a preceding `rename` into
318/// it durable across a crash. A `rename` is atomic against concurrent readers,
319/// but on power loss the new directory entry can still be lost until the parent
320/// directory's own metadata is flushed. Where a platform rejects fsync on a
321/// directory handle (surfaced as `EINVAL`/`InvalidInput`), treat it as a no-op
322/// rather than a write failure.
323fn 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
332/// Walk `<root>/<ns>/<col>/<id>.json` and collect keys matching `prefix`.
333async 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                    // Directory/file names are `segment`-encoded; decode each
375                    // component back to the original key so `list()` round-trips.
376                    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}