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