Skip to main content

yah_object_store/
lib.rs

1//! Object-store trait (put/get/head/delete/list_prefix + conditional `put_if`)
2//! shared by scryer's long-tier Parquet shard storage, the cloud reconciler's
3//! R2 mirror publish, turso-backup's WAL→R2 sink, and the `yah cloud bucket`
4//! CLI + data-tab bucket viewer.
5//!
6//! Lifted from `scryer::long_tier` in R498-F1. `R2ObjectStore` landed in F2.
7//! `put_if` / `etag` (linearizable compare-and-swap on a single object) added
8//! for the W243 global tenant→cell pointer — see `.yah/docs/working/
9//! W243-multi-cell-tenant-mobility.md`.
10//!
11//! @yah:ticket(R498-F1, "lift ObjectStore trait + InMemoryObjectStore into crates/yah/object-store/")
12//! @yah:at(2026-06-09T03:37:49Z)
13//! @yah:status(review)
14//! @yah:parent(R498)
15//! @yah:handoff("Lifted ObjectStore trait + InMemoryObjectStore from scryer::long_tier into new crates/yah/object-store/ crate (yah-object-store package, yah_object_store lib). Trait gained head() with default impl over get(), and delete() (idempotent). Generic Error enum (NotFound/Io/Auth/Backend) replaces the old LongTierError::ObjectStore(String) error path. scryer/long_tier.rs now does pub use yah_object_store::{Error as ObjectStoreError, InMemoryObjectStore, ObjectStore} — every existing call site keeps working unchanged. LongTierError gained #[from] ObjectStoreError variant. cargo check --workspace exit 0; 5/5 object-store unit tests pass. NOTE: scryer's full lib test target was already broken on main (pre-existing missing-.await calls in adapters/journald.rs, adapters/containerd_logs.rs, service.rs — touching 20+ sites) — those are NOT introduced by F1; isolated long_tier tests cannot be run until that gets cleaned up separately.")
16
17pub mod http_ro;
18pub mod r2;
19
20pub use http_ro::HttpReadOnlyObjectStore;
21pub use r2::{ObjectMeta, R2ObjectStore};
22
23/// Re-exported so a caller choosing a directive for
24/// [`ObjectStore::put_cached`] never has to retype the string — two publishers
25/// spelling `no-cache, max-age=0` slightly differently is a difference no test
26/// catches and every CDN honours.
27pub use local_driver::s3_sign::{CACHE_CONTROL_IMMUTABLE, CACHE_CONTROL_NO_CACHE};
28
29use std::collections::HashMap;
30use std::sync::Mutex;
31
32use sha2::{Digest, Sha256};
33use thiserror::Error;
34
35/// Errors a backend may raise.
36///
37/// Variants are deliberately coarse — a backend reports the failure mode
38/// it can plausibly recover or message about, not every wire-level detail.
39#[derive(Debug, Error)]
40pub enum Error {
41    /// The key does not exist (read-side miss). `put` never raises this.
42    #[error("not found: {0}")]
43    NotFound(String),
44
45    /// A conditional write's precondition was not met (S3/R2 `412`). The
46    /// compare-and-swap lost the race: the object changed (or appeared, or
47    /// vanished) since the comparand was read. Re-read and retry. Only
48    /// [`ObjectStore::put_if`] raises this.
49    #[error("precondition failed: {0}")]
50    PreconditionFailed(String),
51
52    /// Network / IO / protocol error from a remote backend.
53    #[error("io: {0}")]
54    Io(String),
55
56    /// Authentication / authorization failure (e.g. SigV4 rejected).
57    #[error("auth: {0}")]
58    Auth(String),
59
60    /// Backend-specific error the caller doesn't need to discriminate.
61    #[error("backend: {0}")]
62    Backend(String),
63}
64
65/// Precondition for a conditional write ([`ObjectStore::put_if`]).
66///
67/// Maps onto S3/R2 conditional-write headers so a caller can perform a
68/// linearizable compare-and-swap on a single object — e.g. the global
69/// tenant→cell pointer in W243 — without any external lock or consensus.
70#[derive(Debug, Clone)]
71pub enum Precondition {
72    /// Write only if no object exists at the key yet (create-only).
73    /// Wire form: `If-None-Match: *`. Fails with [`Error::PreconditionFailed`]
74    /// if any object already exists at the key.
75    IfAbsent,
76
77    /// Write only if the current object's ETag equals this value (optimistic
78    /// concurrency). Wire form: `If-Match: <etag>`. Fails with
79    /// [`Error::PreconditionFailed`] if the stored ETag differs or the key is
80    /// absent. The comparand is an ETag returned by a prior [`ObjectStore::put_if`]
81    /// or [`ObjectStore::etag`].
82    IfMatch(String),
83}
84
85/// Minimal synchronous object-store surface.
86///
87/// Production impls connect to R2 / MinIO via AWS Sig V4. Tests inject
88/// [`InMemoryObjectStore`] so no network is required.
89///
90/// All methods are synchronous; async backends should block_on internally or
91/// expose a separate async trait alongside this one if the consumer is in
92/// a tokio context. (Scryer's long-tier rollover runs on a blocking thread.)
93pub trait ObjectStore: Send + Sync {
94    /// Write `data` at `key`. Overwrites any existing object unconditionally.
95    ///
96    /// Sets no `Cache-Control`. For an object a browser or CDN will re-read —
97    /// anything at a fixed, mutable key — reach for
98    /// [`put_cached`](ObjectStore::put_cached) instead.
99    fn put(&self, key: &str, data: Vec<u8>) -> Result<(), Error>;
100
101    /// Write `data` at `key` with an explicit `Cache-Control` (R703-B8).
102    ///
103    /// Every CLI-driven publish went through [`put`](ObjectStore::put), which
104    /// sets no cache directive at all — so `yah-desktop/latest.json`, the object
105    /// the Tauri updater polls forever, shipped with nothing telling a client
106    /// how long it may hold it. `.github/workflows/release.yml` has always
107    /// tagged the same objects correctly, which is why the CI-published
108    /// manifests answer `no-cache, max-age=0` and the CLI-published ones do not.
109    /// Use [`CACHE_CONTROL_IMMUTABLE`] for versioned, content-addressed keys and
110    /// [`CACHE_CONTROL_NO_CACHE`] for mutable pointers.
111    ///
112    /// The default impl **fails** rather than falling back to `put`, for the
113    /// same reason [`put_if`](ObjectStore::put_if) does: a backend that cannot
114    /// set the header must not report success as though it had. A caller that
115    /// only wants best-effort can call `put` explicitly and mean it.
116    fn put_cached(&self, _key: &str, _data: Vec<u8>, _cache_control: &str) -> Result<(), Error> {
117        Err(Error::Backend(
118            "cache-control on put (put_cached) not supported by this backend".into(),
119        ))
120    }
121
122    /// Read bytes at `key`. Returns `None` when the key does not exist —
123    /// `NotFound` is reserved for ambiguous cases (HEAD-then-GET race etc.).
124    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error>;
125
126    /// Returns true when `key` exists. Cheaper than `get` for backends that
127    /// support HEAD; the default impl falls back to `get(...).is_some()`.
128    fn head(&self, key: &str) -> Result<bool, Error> {
129        Ok(self.get(key)?.is_some())
130    }
131
132    /// Remove `key`. Idempotent — succeeds whether or not the key existed.
133    fn delete(&self, key: &str) -> Result<(), Error>;
134
135    /// List all keys with the given prefix (prefix-match, not glob).
136    fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, Error>;
137
138    /// Conditionally write `data` at `key`, returning the resulting ETag.
139    ///
140    /// An atomic compare-and-swap against `cond`. On a failed precondition the
141    /// store is left untouched and [`Error::PreconditionFailed`] is returned —
142    /// the caller re-reads ([`etag`](ObjectStore::etag)) and retries. The
143    /// returned ETag is the comparand for the next [`Precondition::IfMatch`] in
144    /// a CAS chain, so a single writer can advance a pointer without re-reading.
145    ///
146    /// The default impl returns [`Error::Backend`]: a backend that cannot offer
147    /// an atomic conditional write must **not** silently emulate it with
148    /// `get`-then-`put` — that would break the linearizability callers depend on
149    /// (W243's cross-cell pointer fence). Backends that support it override this.
150    fn put_if(&self, _key: &str, _data: Vec<u8>, _cond: Precondition) -> Result<String, Error> {
151        Err(Error::Backend(
152            "conditional put (put_if) not supported by this backend".into(),
153        ))
154    }
155
156    /// Current ETag of `key`, or `None` if absent.
157    ///
158    /// The comparand a caller reads before a [`Precondition::IfMatch`] CAS. The
159    /// default impl returns [`Error::Backend`]; backends supporting `put_if`
160    /// override it.
161    fn etag(&self, _key: &str) -> Result<Option<String>, Error> {
162        Err(Error::Backend("etag not supported by this backend".into()))
163    }
164
165    /// Where `key` lives, in a form an operator can act on — a URL for a remote
166    /// backend, an opaque descriptor otherwise (R746-F1).
167    ///
168    /// A `Result::Err` from a store already carries *what* failed; this carries
169    /// *where it looked*, which is the half a caller cannot reconstruct because
170    /// it holds only a `&dyn ObjectStore` and the origin is private to the impl.
171    /// A node reporting "no runtime asset for mesofact/0.8.20" is a shrug; one
172    /// reporting the URL it GET'd is a curl away from a diagnosis. The default
173    /// returns the bare key, so a backend that has no meaningful location (the
174    /// in-memory test double) says nothing untrue.
175    fn locate(&self, key: &str) -> String {
176        key.to_string()
177    }
178}
179
180/// ETag for an object's bytes. S3/R2 return the quoted hex MD5 of the body for
181/// a single-part PUT; the in-memory double uses a quoted hex SHA-256 instead —
182/// the exact digest is opaque to callers, only equality across reads matters.
183fn etag_of(data: &[u8]) -> String {
184    let mut h = Sha256::new();
185    h.update(data);
186    format!("\"{}\"", hex::encode(h.finalize()))
187}
188
189/// In-memory object store for tests and local development.
190///
191/// Thread-safe; all ops hold a `Mutex` for the minimum duration. `put_if` holds
192/// the lock across the read+write so the compare-and-swap is genuinely atomic,
193/// matching R2's server-side conditional-write semantics.
194pub struct InMemoryObjectStore {
195    /// key → (bytes, etag). The etag is recomputed on every write.
196    objects: Mutex<HashMap<String, (Vec<u8>, String)>>,
197    /// key → `Cache-Control`, for the keys written through
198    /// [`ObjectStore::put_cached`] (R703-B8). Kept beside `objects` rather than
199    /// widening its tuple so the CAS paths stay untouched. Recorded at all so a
200    /// publish path can be *tested* for its cache directives — the bug this
201    /// exists for shipped precisely because nothing could assert on them.
202    cache_control: Mutex<HashMap<String, String>>,
203}
204
205impl Default for InMemoryObjectStore {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211impl InMemoryObjectStore {
212    pub fn new() -> Self {
213        Self {
214            objects: Mutex::new(HashMap::new()),
215            cache_control: Mutex::new(HashMap::new()),
216        }
217    }
218
219    /// Returns true when `key` exists (test helper — synchronous, no Result).
220    pub fn contains_key(&self, key: &str) -> bool {
221        self.objects.lock().unwrap().contains_key(key)
222    }
223
224    /// Keys currently stored (test helper).
225    pub fn keys(&self) -> Vec<String> {
226        self.objects.lock().unwrap().keys().cloned().collect()
227    }
228
229    /// `Cache-Control` the last write to `key` carried, or `None` if it was
230    /// written through plain [`ObjectStore::put`] (test helper).
231    pub fn cache_control(&self, key: &str) -> Option<String> {
232        self.cache_control.lock().unwrap().get(key).cloned()
233    }
234}
235
236impl ObjectStore for InMemoryObjectStore {
237    fn put(&self, key: &str, data: Vec<u8>) -> Result<(), Error> {
238        let etag = etag_of(&data);
239        self.objects.lock().unwrap().insert(key.to_string(), (data, etag));
240        // An unqualified put clears any directive a prior write set: the object
241        // was replaced, and leaving the old header recorded would let a test
242        // pass on a `Cache-Control` the real store would no longer be sending.
243        self.cache_control.lock().unwrap().remove(key);
244        Ok(())
245    }
246
247    fn put_cached(&self, key: &str, data: Vec<u8>, cache_control: &str) -> Result<(), Error> {
248        self.put(key, data)?;
249        self.cache_control
250            .lock()
251            .unwrap()
252            .insert(key.to_string(), cache_control.to_string());
253        Ok(())
254    }
255
256    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
257        Ok(self.objects.lock().unwrap().get(key).map(|(d, _)| d.clone()))
258    }
259
260    fn delete(&self, key: &str) -> Result<(), Error> {
261        self.objects.lock().unwrap().remove(key);
262        Ok(())
263    }
264
265    fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, Error> {
266        let g = self.objects.lock().unwrap();
267        Ok(g.keys().filter(|k| k.starts_with(prefix)).cloned().collect())
268    }
269
270    fn etag(&self, key: &str) -> Result<Option<String>, Error> {
271        Ok(self.objects.lock().unwrap().get(key).map(|(_, e)| e.clone()))
272    }
273
274    fn put_if(&self, key: &str, data: Vec<u8>, cond: Precondition) -> Result<String, Error> {
275        // One lock across check-then-write = atomic CAS.
276        let mut g = self.objects.lock().unwrap();
277        match (&cond, g.get(key)) {
278            (Precondition::IfAbsent, Some(_)) => {
279                return Err(Error::PreconditionFailed(format!(
280                    "IfAbsent: {key} already exists"
281                )));
282            }
283            (Precondition::IfAbsent, None) => {}
284            (Precondition::IfMatch(want), Some((_, have))) if have == want => {}
285            (Precondition::IfMatch(want), Some((_, have))) => {
286                return Err(Error::PreconditionFailed(format!(
287                    "IfMatch {want} != current {have} for {key}"
288                )));
289            }
290            (Precondition::IfMatch(want), None) => {
291                return Err(Error::PreconditionFailed(format!(
292                    "IfMatch {want}: {key} absent"
293                )));
294            }
295        }
296        let etag = etag_of(&data);
297        g.insert(key.to_string(), (data, etag.clone()));
298        Ok(etag)
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn put_get_round_trip() {
308        let s = InMemoryObjectStore::new();
309        s.put("k", b"v".to_vec()).unwrap();
310        assert_eq!(s.get("k").unwrap().as_deref(), Some(&b"v"[..]));
311    }
312
313    #[test]
314    fn get_missing_returns_none() {
315        let s = InMemoryObjectStore::new();
316        assert!(s.get("absent").unwrap().is_none());
317    }
318
319    // ── Cache-Control on put (R703-B8) ──────────────────────────────────────
320
321    #[test]
322    fn put_cached_stores_the_bytes_and_the_directive() {
323        let s = InMemoryObjectStore::new();
324        s.put_cached("yah-desktop/latest.json", b"{}".to_vec(), CACHE_CONTROL_NO_CACHE)
325            .unwrap();
326        assert_eq!(
327            s.get("yah-desktop/latest.json").unwrap().as_deref(),
328            Some(&b"{}"[..])
329        );
330        assert_eq!(
331            s.cache_control("yah-desktop/latest.json").as_deref(),
332            Some("no-cache, max-age=0")
333        );
334    }
335
336    /// A plain `put` records no directive — that IS the bug this ticket names,
337    /// so it has to stay visible rather than be papered over with a default.
338    #[test]
339    fn a_plain_put_records_no_cache_control() {
340        let s = InMemoryObjectStore::new();
341        s.put("k", b"v".to_vec()).unwrap();
342        assert_eq!(s.cache_control("k"), None);
343    }
344
345    /// Overwriting a cached object with a plain `put` must not leave the old
346    /// directive behind: the real store would now be serving those bytes with
347    /// no header, and a test asserting otherwise would be asserting a fiction.
348    #[test]
349    fn a_plain_put_clears_a_previously_set_directive() {
350        let s = InMemoryObjectStore::new();
351        s.put_cached("k", b"a".to_vec(), CACHE_CONTROL_IMMUTABLE).unwrap();
352        assert!(s.cache_control("k").is_some());
353        s.put("k", b"b".to_vec()).unwrap();
354        assert_eq!(s.cache_control("k"), None);
355    }
356
357    /// The two directives are shared constants precisely so two publishers
358    /// cannot spell them differently — a difference no test catches and every
359    /// CDN honours. Pinned against what `.github/workflows/release.yml` sends.
360    #[test]
361    fn the_shared_directives_match_what_ci_publishes() {
362        assert_eq!(CACHE_CONTROL_IMMUTABLE, "public, max-age=31536000, immutable");
363        assert_eq!(CACHE_CONTROL_NO_CACHE, "no-cache, max-age=0");
364    }
365
366    /// A backend that cannot set the header must FAIL rather than silently
367    /// falling back to a directive-less `put` — reporting success for a write
368    /// that did not carry the header is the exact shape of the original bug.
369    #[test]
370    fn a_backend_without_cache_control_support_refuses_rather_than_lying() {
371        struct Bare;
372        impl ObjectStore for Bare {
373            fn put(&self, _k: &str, _d: Vec<u8>) -> Result<(), Error> {
374                Ok(())
375            }
376            fn get(&self, _k: &str) -> Result<Option<Vec<u8>>, Error> {
377                Ok(None)
378            }
379            fn delete(&self, _k: &str) -> Result<(), Error> {
380                Ok(())
381            }
382            fn list_prefix(&self, _p: &str) -> Result<Vec<String>, Error> {
383                Ok(vec![])
384            }
385        }
386        let err = Bare
387            .put_cached("k", b"v".to_vec(), CACHE_CONTROL_NO_CACHE)
388            .unwrap_err();
389        assert!(matches!(err, Error::Backend(_)), "got {err:?}");
390        assert!(err.to_string().contains("put_cached"), "{err}");
391    }
392
393    #[test]
394    fn head_reflects_presence() {
395        let s = InMemoryObjectStore::new();
396        assert!(!s.head("k").unwrap());
397        s.put("k", b"v".to_vec()).unwrap();
398        assert!(s.head("k").unwrap());
399    }
400
401    #[test]
402    fn delete_is_idempotent() {
403        let s = InMemoryObjectStore::new();
404        s.delete("absent").unwrap();
405        s.put("k", b"v".to_vec()).unwrap();
406        s.delete("k").unwrap();
407        assert!(!s.head("k").unwrap());
408        s.delete("k").unwrap();
409    }
410
411    #[test]
412    fn list_prefix_filters() {
413        let s = InMemoryObjectStore::new();
414        s.put("a/1", vec![]).unwrap();
415        s.put("a/2", vec![]).unwrap();
416        s.put("b/1", vec![]).unwrap();
417        let mut got = s.list_prefix("a/").unwrap();
418        got.sort();
419        assert_eq!(got, vec!["a/1".to_string(), "a/2".to_string()]);
420    }
421
422    #[test]
423    fn etag_is_none_when_absent_some_after_write() {
424        let s = InMemoryObjectStore::new();
425        assert!(s.etag("k").unwrap().is_none());
426        s.put("k", b"v".to_vec()).unwrap();
427        let e = s.etag("k").unwrap();
428        assert!(e.is_some());
429        // Same bytes via the plain `put` path produce the same etag.
430        assert_eq!(e, Some(etag_of(b"v")));
431    }
432
433    #[test]
434    fn put_if_absent_creates_then_refuses_overwrite() {
435        let s = InMemoryObjectStore::new();
436        let e1 = s.put_if("p", b"gen1".to_vec(), Precondition::IfAbsent).unwrap();
437        assert_eq!(s.get("p").unwrap().as_deref(), Some(&b"gen1"[..]));
438        assert_eq!(s.etag("p").unwrap().as_deref(), Some(e1.as_str()));
439
440        // A second create-only write must lose — object already exists.
441        let err = s
442            .put_if("p", b"gen2".to_vec(), Precondition::IfAbsent)
443            .unwrap_err();
444        assert!(matches!(err, Error::PreconditionFailed(_)), "got {err:?}");
445        // Untouched.
446        assert_eq!(s.get("p").unwrap().as_deref(), Some(&b"gen1"[..]));
447    }
448
449    #[test]
450    fn put_if_match_drives_a_cas_chain() {
451        // Models the W243 global tenant→cell pointer: each generation bump is an
452        // IfMatch CAS against the prior etag.
453        let s = InMemoryObjectStore::new();
454        let e1 = s.put_if("ptr", b"cell=US,gen=1".to_vec(), Precondition::IfAbsent).unwrap();
455
456        let e2 = s
457            .put_if("ptr", b"cell=EU,gen=2".to_vec(), Precondition::IfMatch(e1.clone()))
458            .unwrap();
459        assert_ne!(e1, e2);
460        assert_eq!(s.get("ptr").unwrap().as_deref(), Some(&b"cell=EU,gen=2"[..]));
461
462        // A stale comparand (e1) must now bounce.
463        let err = s
464            .put_if("ptr", b"cell=US,gen=3".to_vec(), Precondition::IfMatch(e1))
465            .unwrap_err();
466        assert!(matches!(err, Error::PreconditionFailed(_)), "got {err:?}");
467        assert_eq!(s.get("ptr").unwrap().as_deref(), Some(&b"cell=EU,gen=2"[..]));
468
469        // The fresh comparand (e2) wins.
470        s.put_if("ptr", b"cell=US,gen=3".to_vec(), Precondition::IfMatch(e2)).unwrap();
471        assert_eq!(s.get("ptr").unwrap().as_deref(), Some(&b"cell=US,gen=3"[..]));
472    }
473
474    #[test]
475    fn put_if_match_two_writers_only_one_wins() {
476        // The cross-cell fence in miniature: source + target both read the same
477        // pointer etag; exactly one IfMatch may succeed.
478        let s = InMemoryObjectStore::new();
479        let shared = s.put_if("ptr", b"v0".to_vec(), Precondition::IfAbsent).unwrap();
480
481        let a = s.put_if("ptr", b"from-A".to_vec(), Precondition::IfMatch(shared.clone()));
482        let b = s.put_if("ptr", b"from-B".to_vec(), Precondition::IfMatch(shared));
483        assert!(a.is_ok(), "first writer should win: {a:?}");
484        assert!(
485            matches!(b, Err(Error::PreconditionFailed(_))),
486            "second writer must lose: {b:?}"
487        );
488        assert_eq!(s.get("ptr").unwrap().as_deref(), Some(&b"from-A"[..]));
489    }
490
491    #[test]
492    fn put_if_match_absent_key_fails() {
493        let s = InMemoryObjectStore::new();
494        let err = s
495            .put_if("nope", b"x".to_vec(), Precondition::IfMatch("\"whatever\"".into()))
496            .unwrap_err();
497        assert!(matches!(err, Error::PreconditionFailed(_)), "got {err:?}");
498        assert!(!s.contains_key("nope"));
499    }
500
501    /// A backend that implements only the required methods inherits the default
502    /// `put_if`/`etag` — they must report unsupported rather than silently
503    /// emulating a non-atomic CAS. Guards the non-breaking default-impl contract.
504    struct MinimalStore;
505    impl ObjectStore for MinimalStore {
506        fn put(&self, _k: &str, _d: Vec<u8>) -> Result<(), Error> {
507            Ok(())
508        }
509        fn get(&self, _k: &str) -> Result<Option<Vec<u8>>, Error> {
510            Ok(None)
511        }
512        fn delete(&self, _k: &str) -> Result<(), Error> {
513            Ok(())
514        }
515        fn list_prefix(&self, _p: &str) -> Result<Vec<String>, Error> {
516            Ok(vec![])
517        }
518    }
519
520    #[test]
521    fn default_conditional_methods_report_unsupported() {
522        let s = MinimalStore;
523        assert!(matches!(
524            s.put_if("k", vec![], Precondition::IfAbsent),
525            Err(Error::Backend(_))
526        ));
527        assert!(matches!(s.etag("k"), Err(Error::Backend(_))));
528    }
529}