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
23use std::collections::HashMap;
24use std::sync::Mutex;
25
26use sha2::{Digest, Sha256};
27use thiserror::Error;
28
29/// Errors a backend may raise.
30///
31/// Variants are deliberately coarse — a backend reports the failure mode
32/// it can plausibly recover or message about, not every wire-level detail.
33#[derive(Debug, Error)]
34pub enum Error {
35    /// The key does not exist (read-side miss). `put` never raises this.
36    #[error("not found: {0}")]
37    NotFound(String),
38
39    /// A conditional write's precondition was not met (S3/R2 `412`). The
40    /// compare-and-swap lost the race: the object changed (or appeared, or
41    /// vanished) since the comparand was read. Re-read and retry. Only
42    /// [`ObjectStore::put_if`] raises this.
43    #[error("precondition failed: {0}")]
44    PreconditionFailed(String),
45
46    /// Network / IO / protocol error from a remote backend.
47    #[error("io: {0}")]
48    Io(String),
49
50    /// Authentication / authorization failure (e.g. SigV4 rejected).
51    #[error("auth: {0}")]
52    Auth(String),
53
54    /// Backend-specific error the caller doesn't need to discriminate.
55    #[error("backend: {0}")]
56    Backend(String),
57}
58
59/// Precondition for a conditional write ([`ObjectStore::put_if`]).
60///
61/// Maps onto S3/R2 conditional-write headers so a caller can perform a
62/// linearizable compare-and-swap on a single object — e.g. the global
63/// tenant→cell pointer in W243 — without any external lock or consensus.
64#[derive(Debug, Clone)]
65pub enum Precondition {
66    /// Write only if no object exists at the key yet (create-only).
67    /// Wire form: `If-None-Match: *`. Fails with [`Error::PreconditionFailed`]
68    /// if any object already exists at the key.
69    IfAbsent,
70
71    /// Write only if the current object's ETag equals this value (optimistic
72    /// concurrency). Wire form: `If-Match: <etag>`. Fails with
73    /// [`Error::PreconditionFailed`] if the stored ETag differs or the key is
74    /// absent. The comparand is an ETag returned by a prior [`ObjectStore::put_if`]
75    /// or [`ObjectStore::etag`].
76    IfMatch(String),
77}
78
79/// Minimal synchronous object-store surface.
80///
81/// Production impls connect to R2 / MinIO via AWS Sig V4. Tests inject
82/// [`InMemoryObjectStore`] so no network is required.
83///
84/// All methods are synchronous; async backends should block_on internally or
85/// expose a separate async trait alongside this one if the consumer is in
86/// a tokio context. (Scryer's long-tier rollover runs on a blocking thread.)
87pub trait ObjectStore: Send + Sync {
88    /// Write `data` at `key`. Overwrites any existing object unconditionally.
89    fn put(&self, key: &str, data: Vec<u8>) -> Result<(), Error>;
90
91    /// Read bytes at `key`. Returns `None` when the key does not exist —
92    /// `NotFound` is reserved for ambiguous cases (HEAD-then-GET race etc.).
93    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error>;
94
95    /// Returns true when `key` exists. Cheaper than `get` for backends that
96    /// support HEAD; the default impl falls back to `get(...).is_some()`.
97    fn head(&self, key: &str) -> Result<bool, Error> {
98        Ok(self.get(key)?.is_some())
99    }
100
101    /// Remove `key`. Idempotent — succeeds whether or not the key existed.
102    fn delete(&self, key: &str) -> Result<(), Error>;
103
104    /// List all keys with the given prefix (prefix-match, not glob).
105    fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, Error>;
106
107    /// Conditionally write `data` at `key`, returning the resulting ETag.
108    ///
109    /// An atomic compare-and-swap against `cond`. On a failed precondition the
110    /// store is left untouched and [`Error::PreconditionFailed`] is returned —
111    /// the caller re-reads ([`etag`](ObjectStore::etag)) and retries. The
112    /// returned ETag is the comparand for the next [`Precondition::IfMatch`] in
113    /// a CAS chain, so a single writer can advance a pointer without re-reading.
114    ///
115    /// The default impl returns [`Error::Backend`]: a backend that cannot offer
116    /// an atomic conditional write must **not** silently emulate it with
117    /// `get`-then-`put` — that would break the linearizability callers depend on
118    /// (W243's cross-cell pointer fence). Backends that support it override this.
119    fn put_if(&self, _key: &str, _data: Vec<u8>, _cond: Precondition) -> Result<String, Error> {
120        Err(Error::Backend(
121            "conditional put (put_if) not supported by this backend".into(),
122        ))
123    }
124
125    /// Current ETag of `key`, or `None` if absent.
126    ///
127    /// The comparand a caller reads before a [`Precondition::IfMatch`] CAS. The
128    /// default impl returns [`Error::Backend`]; backends supporting `put_if`
129    /// override it.
130    fn etag(&self, _key: &str) -> Result<Option<String>, Error> {
131        Err(Error::Backend("etag not supported by this backend".into()))
132    }
133}
134
135/// ETag for an object's bytes. S3/R2 return the quoted hex MD5 of the body for
136/// a single-part PUT; the in-memory double uses a quoted hex SHA-256 instead —
137/// the exact digest is opaque to callers, only equality across reads matters.
138fn etag_of(data: &[u8]) -> String {
139    let mut h = Sha256::new();
140    h.update(data);
141    format!("\"{}\"", hex::encode(h.finalize()))
142}
143
144/// In-memory object store for tests and local development.
145///
146/// Thread-safe; all ops hold a `Mutex` for the minimum duration. `put_if` holds
147/// the lock across the read+write so the compare-and-swap is genuinely atomic,
148/// matching R2's server-side conditional-write semantics.
149pub struct InMemoryObjectStore {
150    /// key → (bytes, etag). The etag is recomputed on every write.
151    objects: Mutex<HashMap<String, (Vec<u8>, String)>>,
152}
153
154impl Default for InMemoryObjectStore {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160impl InMemoryObjectStore {
161    pub fn new() -> Self {
162        Self { objects: Mutex::new(HashMap::new()) }
163    }
164
165    /// Returns true when `key` exists (test helper — synchronous, no Result).
166    pub fn contains_key(&self, key: &str) -> bool {
167        self.objects.lock().unwrap().contains_key(key)
168    }
169
170    /// Keys currently stored (test helper).
171    pub fn keys(&self) -> Vec<String> {
172        self.objects.lock().unwrap().keys().cloned().collect()
173    }
174}
175
176impl ObjectStore for InMemoryObjectStore {
177    fn put(&self, key: &str, data: Vec<u8>) -> Result<(), Error> {
178        let etag = etag_of(&data);
179        self.objects.lock().unwrap().insert(key.to_string(), (data, etag));
180        Ok(())
181    }
182
183    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
184        Ok(self.objects.lock().unwrap().get(key).map(|(d, _)| d.clone()))
185    }
186
187    fn delete(&self, key: &str) -> Result<(), Error> {
188        self.objects.lock().unwrap().remove(key);
189        Ok(())
190    }
191
192    fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, Error> {
193        let g = self.objects.lock().unwrap();
194        Ok(g.keys().filter(|k| k.starts_with(prefix)).cloned().collect())
195    }
196
197    fn etag(&self, key: &str) -> Result<Option<String>, Error> {
198        Ok(self.objects.lock().unwrap().get(key).map(|(_, e)| e.clone()))
199    }
200
201    fn put_if(&self, key: &str, data: Vec<u8>, cond: Precondition) -> Result<String, Error> {
202        // One lock across check-then-write = atomic CAS.
203        let mut g = self.objects.lock().unwrap();
204        match (&cond, g.get(key)) {
205            (Precondition::IfAbsent, Some(_)) => {
206                return Err(Error::PreconditionFailed(format!(
207                    "IfAbsent: {key} already exists"
208                )));
209            }
210            (Precondition::IfAbsent, None) => {}
211            (Precondition::IfMatch(want), Some((_, have))) if have == want => {}
212            (Precondition::IfMatch(want), Some((_, have))) => {
213                return Err(Error::PreconditionFailed(format!(
214                    "IfMatch {want} != current {have} for {key}"
215                )));
216            }
217            (Precondition::IfMatch(want), None) => {
218                return Err(Error::PreconditionFailed(format!(
219                    "IfMatch {want}: {key} absent"
220                )));
221            }
222        }
223        let etag = etag_of(&data);
224        g.insert(key.to_string(), (data, etag.clone()));
225        Ok(etag)
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn put_get_round_trip() {
235        let s = InMemoryObjectStore::new();
236        s.put("k", b"v".to_vec()).unwrap();
237        assert_eq!(s.get("k").unwrap().as_deref(), Some(&b"v"[..]));
238    }
239
240    #[test]
241    fn get_missing_returns_none() {
242        let s = InMemoryObjectStore::new();
243        assert!(s.get("absent").unwrap().is_none());
244    }
245
246    #[test]
247    fn head_reflects_presence() {
248        let s = InMemoryObjectStore::new();
249        assert!(!s.head("k").unwrap());
250        s.put("k", b"v".to_vec()).unwrap();
251        assert!(s.head("k").unwrap());
252    }
253
254    #[test]
255    fn delete_is_idempotent() {
256        let s = InMemoryObjectStore::new();
257        s.delete("absent").unwrap();
258        s.put("k", b"v".to_vec()).unwrap();
259        s.delete("k").unwrap();
260        assert!(!s.head("k").unwrap());
261        s.delete("k").unwrap();
262    }
263
264    #[test]
265    fn list_prefix_filters() {
266        let s = InMemoryObjectStore::new();
267        s.put("a/1", vec![]).unwrap();
268        s.put("a/2", vec![]).unwrap();
269        s.put("b/1", vec![]).unwrap();
270        let mut got = s.list_prefix("a/").unwrap();
271        got.sort();
272        assert_eq!(got, vec!["a/1".to_string(), "a/2".to_string()]);
273    }
274
275    #[test]
276    fn etag_is_none_when_absent_some_after_write() {
277        let s = InMemoryObjectStore::new();
278        assert!(s.etag("k").unwrap().is_none());
279        s.put("k", b"v".to_vec()).unwrap();
280        let e = s.etag("k").unwrap();
281        assert!(e.is_some());
282        // Same bytes via the plain `put` path produce the same etag.
283        assert_eq!(e, Some(etag_of(b"v")));
284    }
285
286    #[test]
287    fn put_if_absent_creates_then_refuses_overwrite() {
288        let s = InMemoryObjectStore::new();
289        let e1 = s.put_if("p", b"gen1".to_vec(), Precondition::IfAbsent).unwrap();
290        assert_eq!(s.get("p").unwrap().as_deref(), Some(&b"gen1"[..]));
291        assert_eq!(s.etag("p").unwrap().as_deref(), Some(e1.as_str()));
292
293        // A second create-only write must lose — object already exists.
294        let err = s
295            .put_if("p", b"gen2".to_vec(), Precondition::IfAbsent)
296            .unwrap_err();
297        assert!(matches!(err, Error::PreconditionFailed(_)), "got {err:?}");
298        // Untouched.
299        assert_eq!(s.get("p").unwrap().as_deref(), Some(&b"gen1"[..]));
300    }
301
302    #[test]
303    fn put_if_match_drives_a_cas_chain() {
304        // Models the W243 global tenant→cell pointer: each generation bump is an
305        // IfMatch CAS against the prior etag.
306        let s = InMemoryObjectStore::new();
307        let e1 = s.put_if("ptr", b"cell=US,gen=1".to_vec(), Precondition::IfAbsent).unwrap();
308
309        let e2 = s
310            .put_if("ptr", b"cell=EU,gen=2".to_vec(), Precondition::IfMatch(e1.clone()))
311            .unwrap();
312        assert_ne!(e1, e2);
313        assert_eq!(s.get("ptr").unwrap().as_deref(), Some(&b"cell=EU,gen=2"[..]));
314
315        // A stale comparand (e1) must now bounce.
316        let err = s
317            .put_if("ptr", b"cell=US,gen=3".to_vec(), Precondition::IfMatch(e1))
318            .unwrap_err();
319        assert!(matches!(err, Error::PreconditionFailed(_)), "got {err:?}");
320        assert_eq!(s.get("ptr").unwrap().as_deref(), Some(&b"cell=EU,gen=2"[..]));
321
322        // The fresh comparand (e2) wins.
323        s.put_if("ptr", b"cell=US,gen=3".to_vec(), Precondition::IfMatch(e2)).unwrap();
324        assert_eq!(s.get("ptr").unwrap().as_deref(), Some(&b"cell=US,gen=3"[..]));
325    }
326
327    #[test]
328    fn put_if_match_two_writers_only_one_wins() {
329        // The cross-cell fence in miniature: source + target both read the same
330        // pointer etag; exactly one IfMatch may succeed.
331        let s = InMemoryObjectStore::new();
332        let shared = s.put_if("ptr", b"v0".to_vec(), Precondition::IfAbsent).unwrap();
333
334        let a = s.put_if("ptr", b"from-A".to_vec(), Precondition::IfMatch(shared.clone()));
335        let b = s.put_if("ptr", b"from-B".to_vec(), Precondition::IfMatch(shared));
336        assert!(a.is_ok(), "first writer should win: {a:?}");
337        assert!(
338            matches!(b, Err(Error::PreconditionFailed(_))),
339            "second writer must lose: {b:?}"
340        );
341        assert_eq!(s.get("ptr").unwrap().as_deref(), Some(&b"from-A"[..]));
342    }
343
344    #[test]
345    fn put_if_match_absent_key_fails() {
346        let s = InMemoryObjectStore::new();
347        let err = s
348            .put_if("nope", b"x".to_vec(), Precondition::IfMatch("\"whatever\"".into()))
349            .unwrap_err();
350        assert!(matches!(err, Error::PreconditionFailed(_)), "got {err:?}");
351        assert!(!s.contains_key("nope"));
352    }
353
354    /// A backend that implements only the required methods inherits the default
355    /// `put_if`/`etag` — they must report unsupported rather than silently
356    /// emulating a non-atomic CAS. Guards the non-breaking default-impl contract.
357    struct MinimalStore;
358    impl ObjectStore for MinimalStore {
359        fn put(&self, _k: &str, _d: Vec<u8>) -> Result<(), Error> {
360            Ok(())
361        }
362        fn get(&self, _k: &str) -> Result<Option<Vec<u8>>, Error> {
363            Ok(None)
364        }
365        fn delete(&self, _k: &str) -> Result<(), Error> {
366            Ok(())
367        }
368        fn list_prefix(&self, _p: &str) -> Result<Vec<String>, Error> {
369            Ok(vec![])
370        }
371    }
372
373    #[test]
374    fn default_conditional_methods_report_unsupported() {
375        let s = MinimalStore;
376        assert!(matches!(
377            s.put_if("k", vec![], Precondition::IfAbsent),
378            Err(Error::Backend(_))
379        ));
380        assert!(matches!(s.etag("k"), Err(Error::Backend(_))));
381    }
382}