Skip to main content

gonzalo_store_s3/
lib.rs

1//! S3-compatible object-store substrate. One JSON object per record at
2//! key `namespace/collection/id.json`.
3
4use async_trait::async_trait;
5use aws_sdk_s3::Client;
6use aws_sdk_s3::error::ProvideErrorMetadata;
7use aws_sdk_s3::operation::head_object::HeadObjectError;
8use aws_sdk_s3::primitives::DateTime;
9use std::collections::{BTreeMap, BTreeSet};
10use std::sync::{Arc, RwLock};
11use std::time::SystemTime;
12
13use gonzalo_core::{
14    BlobEntry, BlobStore, ContentHash, CoreError, DEFAULT_ANCESTOR_CAP, DeletePlan, DeleteResult,
15    Identity, KeyPrefix, PurgePlan, PutPlan, PutResult, Record, RecordKey, Result, Revision,
16    decode_segment, now_ms, object_key, plan_delete, plan_purge, plan_put, plan_put_raw,
17    validate_ancestor_cap,
18};
19
20/// Key prefix under which content-addressed blobs live (`blobs/<hash>`), kept
21/// separate from record objects (`namespace/collection/id.json`).
22const BLOB_PREFIX: &str = "blobs/";
23
24/// What one `ListObjectsV2` traversal saw: the record keys under the prefix,
25/// and which of them carry a tombstone marker (ADR 0025).
26#[derive(Default)]
27struct Listing {
28    records: Vec<RecordKey>,
29    /// Marked keys, each with the ETag its marker carried in this listing.
30    /// Removing a marker is conditional on that ETag, so a marker rewritten
31    /// since is left alone (see `delete_marker_if_match`).
32    markers: BTreeMap<RecordKey, String>,
33}
34
35pub struct S3Store {
36    client: Client,
37    bucket: String,
38    /// Maximum `Record::ancestors` length kept on every committed write
39    /// (spec §3.9). Defaults to [`DEFAULT_ANCESTOR_CAP`].
40    cap: usize,
41    /// Encoded `(namespace, collection)` pairs known to carry the marked flag
42    /// (ADR 0025), shared across [`handle`](S3Store::handle) clones.
43    ///
44    /// Only ever added to: the flag is set once and never cleared, so a cached
45    /// `true` cannot go stale. An *unmarked* collection is deliberately not
46    /// cached — it re-checks, which costs one `HeadObject` on a path that is
47    /// already paying a read per key.
48    marked: Arc<RwLock<BTreeSet<(String, String)>>>,
49}
50
51impl S3Store {
52    /// Build a store from an explicit client and bucket. Use
53    /// [`S3Store::connect`] for the common env/endpoint path.
54    pub fn new(client: Client, bucket: impl Into<String>) -> Self {
55        Self {
56            client,
57            bucket: bucket.into(),
58            cap: DEFAULT_ANCESTOR_CAP,
59            marked: Arc::new(RwLock::new(BTreeSet::new())),
60        }
61    }
62
63    /// Override the ancestor cap (spec §3.9). A cap of `0` is rejected.
64    pub fn with_ancestor_cap(mut self, cap: usize) -> Result<Self> {
65        self.cap = validate_ancestor_cap(cap)?;
66        Ok(self)
67    }
68
69    /// Connect using the ambient AWS config (env, profile, IRSA, etc.). If
70    /// `endpoint` is `Some`, target an S3-compatible server (MinIO/Garage, etc.)
71    /// with path-style addressing; if `region` is `Some`, override the ambient
72    /// region (else the AWS env/profile region applies).
73    pub async fn connect(
74        bucket: impl Into<String>,
75        endpoint: Option<String>,
76        region: Option<String>,
77    ) -> Self {
78        let base = aws_config::load_from_env().await;
79        let mut builder = aws_sdk_s3::config::Builder::from(&base);
80        if let Some(ep) = endpoint {
81            builder = builder.endpoint_url(ep).force_path_style(true);
82        }
83        if let Some(r) = region {
84            builder = builder.region(aws_sdk_s3::config::Region::new(r));
85        }
86        let client = Client::from_conf(builder.build());
87        Self::new(client, bucket)
88    }
89
90    /// An owned handle to the same bucket, for a spawned task. The client is a
91    /// cheap clone sharing one connection pool.
92    fn handle(&self) -> Self {
93        Self {
94            client: self.client.clone(),
95            bucket: self.bucket.clone(),
96            cap: self.cap,
97            // The Arc, not the set: a handle must see what its parent learned.
98            marked: Arc::clone(&self.marked),
99        }
100    }
101
102    async fn read(&self, key: &RecordKey) -> Result<Option<Record>> {
103        Ok(self.read_with_etag(key).await?.map(|(rec, _)| rec))
104    }
105
106    /// Like [`read`](Self::read) but also returns the object's S3 ETag, which
107    /// [`write_planned`](Self::write_planned) feeds back as the write
108    /// precondition to make every compare-and-swap atomic (closing the
109    /// read-then-write TOCTOU). Returns tombstones: this is a raw read.
110    async fn read_with_etag(&self, key: &RecordKey) -> Result<Option<(Record, String)>> {
111        let obj = object_key(key);
112        match self
113            .client
114            .get_object()
115            .bucket(&self.bucket)
116            .key(&obj)
117            .send()
118            .await
119        {
120            Ok(resp) => {
121                // A missing ETag used to default to `""`, which later went out
122                // as `If-Match: ""`. A server that omits the ETag and reads an
123                // empty `If-Match` as "no condition" would turn every
124                // compare-and-swap into a blind overwrite or delete, so refuse
125                // instead of writing unconditionally (gonzalo#286).
126                let etag = resp
127                    .e_tag()
128                    .ok_or_else(|| {
129                        CoreError::Backend(format!(
130                            "s3: GetObject for {obj} returned no ETag; conditional writes are unsafe against this backend"
131                        ))
132                    })?
133                    .to_string();
134                let data = resp
135                    .body
136                    .collect()
137                    .await
138                    .map_err(|e| CoreError::Backend(e.to_string()))?
139                    .into_bytes();
140                let record =
141                    serde_json::from_slice(&data).map_err(|e| CoreError::Serde(e.to_string()))?;
142                Ok(Some((record, etag)))
143            }
144            Err(e) => {
145                let svc = e.into_service_error();
146                if svc.is_no_such_key() {
147                    Ok(None)
148                } else {
149                    Err(CoreError::Backend(svc.to_string()))
150                }
151            }
152        }
153    }
154
155    /// Every record key under `prefix`, tombstones included (the raw listing).
156    async fn list_keys(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
157        Ok(self.list_objects(prefix).await?.records)
158    }
159
160    /// One `ListObjectsV2` traversal, sorting what it sees into record keys and
161    /// the keys that carry a tombstone marker (ADR 0025). Markers are siblings
162    /// of their records, so the same prefix covers both and no second traversal
163    /// is needed. Paginates off the continuation token (see
164    /// [`next_continuation`]).
165    async fn list_objects(&self, prefix: &KeyPrefix) -> Result<Listing> {
166        let mut s3_prefix = String::new();
167        if let Some(ns) = &prefix.namespace {
168            s3_prefix.push_str(&gonzalo_core::segment(ns));
169            s3_prefix.push('/');
170            if let Some(col) = &prefix.collection {
171                s3_prefix.push_str(&gonzalo_core::segment(col));
172                s3_prefix.push('/');
173            }
174        }
175        let mut out = Listing::default();
176        let mut continuation: Option<String> = None;
177        loop {
178            let mut req = self.client.list_objects_v2().bucket(&self.bucket);
179            if !s3_prefix.is_empty() {
180                req = req.prefix(&s3_prefix);
181            }
182            if let Some(token) = &continuation {
183                req = req.continuation_token(token);
184            }
185            let resp = req
186                .send()
187                .await
188                .map_err(|e| CoreError::Backend(e.into_service_error().to_string()))?;
189            for obj in resp.contents() {
190                let Some(k) = obj.key() else { continue };
191                if let Some(key) = parse_object_key(k) {
192                    if prefix.matches(&key) {
193                        out.records.push(key);
194                    }
195                } else if let Some(key) = parse_marker_key(k)
196                    && prefix.matches(&key)
197                    && let Some(etag) = obj.e_tag()
198                {
199                    out.markers.insert(key, etag.to_string());
200                }
201            }
202            match next_continuation(resp.is_truncated(), resp.next_continuation_token()) {
203                Some(token) => continuation = Some(token),
204                None => break,
205            }
206        }
207        Ok(out)
208    }
209
210    /// Serialize `record` and `PutObject` it at its key under `pre`. A writer
211    /// that changed the object after our read makes this a 412, reported as
212    /// [`WriteOutcome::LostRace`] instead of clobbering its write.
213    async fn put_record_if(&self, record: &Record, pre: Precondition) -> Result<WriteOutcome> {
214        let bytes =
215            serde_json::to_vec_pretty(record).map_err(|e| CoreError::Serde(e.to_string()))?;
216        let mut req = self
217            .client
218            .put_object()
219            .bucket(&self.bucket)
220            .key(object_key(&record.key))
221            .body(bytes.into());
222        req = match pre {
223            Precondition::IfAbsent => req.if_none_match("*"),
224            Precondition::IfMatch(tag) => req.if_match(tag),
225        };
226        match req.send().await {
227            Ok(_) => Ok(WriteOutcome::Applied),
228            Err(e) => {
229                let svc = e.into_service_error();
230                match race_kind(svc.code()) {
231                    Some(kind) => Ok(WriteOutcome::LostRace(kind)),
232                    None => Err(CoreError::Backend(svc.to_string())),
233                }
234            }
235        }
236    }
237
238    /// `DeleteObject` at `key` only if it still carries `etag` (`If-Match`).
239    async fn delete_record_if_match(&self, key: &RecordKey, etag: String) -> Result<WriteOutcome> {
240        match self
241            .client
242            .delete_object()
243            .bucket(&self.bucket)
244            .key(object_key(key))
245            .if_match(etag)
246            .send()
247            .await
248        {
249            Ok(_) => Ok(WriteOutcome::Applied),
250            Err(e) => {
251                let svc = e.into_service_error();
252                match race_kind(svc.code()) {
253                    Some(kind) => Ok(WriteOutcome::LostRace(kind)),
254                    None => Err(CoreError::Backend(svc.to_string())),
255                }
256            }
257        }
258    }
259
260    /// Whether `list` may treat an unmarked key in this collection as live —
261    /// true once the collection carries the marked flag (ADR 0025).
262    ///
263    /// A bucket written before markers existed has tombstones with no marker,
264    /// and trusting their absence there would resurrect deleted records. So
265    /// trust is earned per collection, by a pass that read every key and
266    /// backfilled what was missing.
267    pub async fn collection_marked(&self, namespace: &str, collection: &str) -> Result<bool> {
268        let pair = (
269            gonzalo_core::segment(namespace),
270            gonzalo_core::segment(collection),
271        );
272        if self.marked.read().unwrap().contains(&pair) {
273            return Ok(true);
274        }
275        let found = match self
276            .client
277            .head_object()
278            .bucket(&self.bucket)
279            .key(marked_flag_key(namespace, collection))
280            .send()
281            .await
282        {
283            Ok(_) => true,
284            Err(e) => {
285                let svc = e.into_service_error();
286                if svc.is_not_found() {
287                    false
288                } else {
289                    return Err(CoreError::Backend(svc.to_string()));
290                }
291            }
292        };
293        if found {
294            self.marked.write().unwrap().insert(pair);
295        }
296        Ok(found)
297    }
298
299    /// Record that every tombstone in this collection carries a marker.
300    ///
301    /// Only for a caller that just read every key in the collection and wrote
302    /// the markers that were missing — the flag is what later listings trust
303    /// instead of reading.
304    pub async fn mark_collection(&self, namespace: &str, collection: &str) -> Result<()> {
305        self.client
306            .put_object()
307            .bucket(&self.bucket)
308            .key(marked_flag_key(namespace, collection))
309            .body(b"1".to_vec().into())
310            .send()
311            .await
312            .map_err(|e| CoreError::Backend(e.into_service_error().to_string()))?;
313        self.marked.write().unwrap().insert((
314            gonzalo_core::segment(namespace),
315            gonzalo_core::segment(collection),
316        ));
317        Ok(())
318    }
319
320    /// Write the tombstone marker for `key` (ADR 0025).
321    /// Unconditional: the marker carries no version, so writing one that
322    /// already exists is a no-op rather than a race to lose.
323    async fn put_marker(&self, key: &RecordKey, revision: &Revision) -> Result<()> {
324        // The body is the tombstone's revision, which makes each marker
325        // distinguishable from the next one written for the same key: a
326        // tombstone's counter always exceeds the record it replaced, so no two
327        // markers for a key ever share an ETag. `delete_marker_if_match` relies
328        // on that to tell "the marker I resolved" from "a marker written since".
329        let body = serde_json::to_vec(revision).map_err(|e| CoreError::Serde(e.to_string()))?;
330        self.client
331            .put_object()
332            .bucket(&self.bucket)
333            .key(marker_key(key))
334            .body(body.into())
335            .send()
336            .await
337            .map(|_| ())
338            .map_err(|e| CoreError::Backend(e.into_service_error().to_string()))
339    }
340
341    /// Remove the marker for `key` only if it still carries `etag`.
342    ///
343    /// The conditional is what makes removal safe to do concurrently with a
344    /// delete. A listing resolves a marker to "the record is live" or "the
345    /// record is gone", then removes it — but between those two steps another
346    /// writer may have deleted the record, writing *its* marker first and the
347    /// tombstone after. An unconditional removal landing in that window would
348    /// strand a tombstone with no marker, which a flagged collection reads as
349    /// live. The new marker carries a different revision, so its ETag differs
350    /// and this removal turns into a no-op (412) instead.
351    async fn delete_marker_if_match(&self, key: &RecordKey, etag: &str) -> Result<()> {
352        match self
353            .client
354            .delete_object()
355            .bucket(&self.bucket)
356            .key(marker_key(key))
357            .if_match(etag)
358            .send()
359            .await
360        {
361            Ok(_) => Ok(()),
362            Err(e) => {
363                let svc = e.into_service_error();
364                // A marker rewritten since the listing is not ours to remove,
365                // and one already gone needs no removing.
366                if race_kind(svc.code()).is_some() || svc.code() == Some("NoSuchKey") {
367                    Ok(())
368                } else {
369                    Err(CoreError::Backend(svc.to_string()))
370                }
371            }
372        }
373    }
374
375    /// Whether `key` currently carries a tombstone marker.
376    ///
377    /// For inspecting the layout — tests and operational debugging. `list`
378    /// reads markers out of the listing it already made instead, which is the
379    /// entire point of putting them in the key space (ADR 0025).
380    pub async fn marker_exists(&self, key: &RecordKey) -> Result<bool> {
381        match self
382            .client
383            .head_object()
384            .bucket(&self.bucket)
385            .key(marker_key(key))
386            .send()
387            .await
388        {
389            Ok(_) => Ok(true),
390            Err(e) => {
391                let svc = e.into_service_error();
392                if svc.is_not_found() {
393                    Ok(false)
394                } else {
395                    Err(CoreError::Backend(svc.to_string()))
396                }
397            }
398        }
399    }
400
401    /// Write a marker for `key` without writing a tombstone — the state a
402    /// crash between the two leaves behind. Test seam for the stale-marker
403    /// cases of ADR 0025; production code always pairs the two writes.
404    #[doc(hidden)]
405    pub async fn write_marker_for_test(&self, key: &RecordKey) -> Result<()> {
406        self.put_marker(key, &Revision::initial(b"stale marker"))
407            .await
408    }
409
410    /// Remove a marker while leaving its tombstone — the pre-ADR-0025 layout.
411    /// Test seam for the upgrade case; nothing in production strips a marker
412    /// from a record that is still deleted.
413    #[doc(hidden)]
414    pub async fn remove_marker_for_test(&self, key: &RecordKey) -> Result<()> {
415        self.client
416            .delete_object()
417            .bucket(&self.bucket)
418            .key(marker_key(key))
419            .send()
420            .await
421            .map(|_| ())
422            .map_err(|e| CoreError::Backend(e.into_service_error().to_string()))
423    }
424
425    /// Read `keys` in bounded-concurrency batches and report which are live.
426    /// A key absent from the map was not read.
427    ///
428    /// The reads run concurrently because one at a time multiplied every key's
429    /// latency by a round trip, which is what made a large collection slow
430    /// rather than merely expensive (gonzalo#286).
431    async fn read_liveness(&self, keys: &[RecordKey]) -> Result<BTreeMap<RecordKey, Liveness>> {
432        let mut live = BTreeMap::new();
433        for batch in keys.chunks(LIST_READ_CONCURRENCY) {
434            let mut reads = tokio::task::JoinSet::new();
435            for key in batch {
436                // A handle per task: the client is a cheap clone (it shares one
437                // connection pool), and a task needs to own what it reads.
438                let store = self.handle();
439                let key = key.clone();
440                reads.spawn(async move {
441                    let liveness = listed_as_live(store.read(&key).await)?;
442                    Ok::<_, CoreError>((key, liveness))
443                });
444            }
445            while let Some(joined) = reads.join_next().await {
446                let (key, liveness) = joined.map_err(|e| CoreError::Backend(e.to_string()))??;
447                live.insert(key, liveness);
448            }
449        }
450        Ok(live)
451    }
452
453    /// The compare-and-swap loop behind every mutating method. Each attempt
454    /// reads the object and its ETag, asks `plan` what to do given that exact
455    /// record, and carries it out with a conditional write gated on the same
456    /// ETag. A lost race (412, 409, or the object vanishing to a concurrent
457    /// purge) means a concurrent writer changed the object after our read, so
458    /// the next attempt re-reads and re-plans. The planner therefore always
459    /// decides against the true current record, as it does under the fs and
460    /// git stores' locks. Gives up after [`MAX_WRITE_ATTEMPTS`] attempts.
461    async fn write_planned<T, P>(&self, key: &RecordKey, plan: P) -> Result<T>
462    where
463        T: Send,
464        P: Fn(Option<&Record>) -> Result<Planned<T>> + Sync,
465    {
466        let plan = &plan;
467        // What the previous attempt sent, with the answer it would have
468        // returned. A conditional PutObject can apply on the server and still
469        // look like a failure — the response times out, the SDK retries, and
470        // the server answers 412 because the object now carries the new ETag.
471        // Without this, the re-read finds our own write and the planner calls
472        // it someone else's, returning a Conflict for a write that committed.
473        // `delete_as` and `purge` already converge here; this makes writes
474        // agree with them and with `MemStore` (gonzalo#286).
475        let pending: std::sync::Mutex<Option<(Record, T)>> = std::sync::Mutex::new(None);
476        let pending = &pending;
477        retry_on_lost_race(key, move || async move {
478            let current = self.read_with_etag(key).await?;
479
480            // Take the previous attempt's record before any await, so the lock
481            // is never held across one.
482            let previous = pending.lock().unwrap().take();
483            if let Some((sent, answer)) = previous
484                && own_write_landed(&sent, current.as_ref().map(|(rec, _)| rec))
485            {
486                return Ok(Step::Done(answer));
487            }
488
489            let etag = current.as_ref().map(|(_, tag)| tag.as_str());
490            let (outcome, answer) = match plan(current.as_ref().map(|(rec, _)| rec))? {
491                Planned::Finish(answer) => return Ok(Step::Done(answer)),
492                Planned::Put(record, answer) => {
493                    // The marker goes first (ADR 0025). A crash after this
494                    // leaves a stale marker, which costs one wasted read; a
495                    // crash after the tombstone would leave one *unmarked*,
496                    // which is the single state a flagged collection cannot
497                    // survive — `list` would show the deleted record as live.
498                    if record.is_tombstone() {
499                        self.put_marker(key, &record.revision).await?;
500                    }
501                    let outcome = self.put_record_if(&record, precondition(etag)).await?;
502                    if let WriteOutcome::LostRace(_) = outcome {
503                        // Remember it, so the next attempt can recognise its own
504                        // write if this "failure" was really an ambiguous commit.
505                        *pending.lock().unwrap() = Some((record, answer));
506                        return Ok(match outcome {
507                            WriteOutcome::LostRace(kind) => Step::Retry(kind),
508                            WriteOutcome::Applied => unreachable!("checked above"),
509                        });
510                    }
511                    // A recreation leaves the old marker behind on purpose. It
512                    // is now stale — the key is live — which costs the next
513                    // listing one read and nothing else, and that listing
514                    // removes it under the ETag it saw. Removing it here
515                    // instead would race a concurrent delete: the other writer
516                    // puts its marker before its tombstone, and a removal
517                    // landing between the two would strand an unmarked
518                    // tombstone. Writers only ever add markers.
519                    (outcome, answer)
520                }
521                Planned::Remove(answer) => {
522                    // Planners only remove a record they were given, so an ETag
523                    // was read. The empty fallback can never match; it would
524                    // just 412 and re-plan.
525                    let tag = etag.unwrap_or_default().to_string();
526                    // Purge leaves the marker too, for the same reason, and the
527                    // next listing sweeps it as an orphan.
528                    (self.delete_record_if_match(key, tag).await?, answer)
529                }
530            };
531            Ok(match outcome {
532                WriteOutcome::Applied => Step::Done(answer),
533                WriteOutcome::LostRace(kind) => Step::Retry(kind),
534            })
535        })
536        .await
537    }
538}
539
540/// The S3 precondition that enforces OCC atomically at write time, chosen from
541/// the caller's `expected` revision and the ETag read for the object.
542#[derive(Debug, PartialEq, Eq)]
543enum Precondition {
544    /// Create only if the object is still absent (`If-None-Match: *`).
545    IfAbsent,
546    /// Replace only if the object still carries this ETag (`If-Match: <etag>`).
547    IfMatch(String),
548}
549
550/// Map the ETag read for the object to the write precondition. If an object
551/// was read (a live record *or a tombstone*), the write must replace exactly
552/// that version (`If-Match`). If none was read, it must still be absent
553/// (`If-None-Match: *`). This depends on what's stored, not on the caller's
554/// `expected`: recreation is `put(_, None)` over an existing tombstone object,
555/// so keying off `expected` would pick `IfAbsent` and fail with 412 every time.
556fn precondition(etag: Option<&str>) -> Precondition {
557    match etag {
558        Some(tag) => Precondition::IfMatch(tag.to_string()),
559        None => Precondition::IfAbsent,
560    }
561}
562
563/// Whether an S3 error code denotes a failed write precondition (HTTP 412) —
564/// i.e. a concurrent writer won the race, which OCC surfaces as a `Conflict`.
565fn is_precondition_failed(code: Option<&str>) -> bool {
566    matches!(code, Some("PreconditionFailed"))
567}
568
569/// Why a conditional write did not apply. The kinds differ in what the caller
570/// should do next (gonzalo#286).
571#[derive(Debug, Clone, Copy, PartialEq, Eq)]
572enum RaceKind {
573    /// `412`: the object changed after our read, or `NoSuchKey`: it vanished to
574    /// a concurrent purge. Another writer already finished, so re-read and
575    /// re-plan straight away.
576    Settled,
577    /// `409 ConditionalRequestConflict`: AWS returns this while a conflicting
578    /// conditional write is still **in flight**. Retrying immediately can burn
579    /// every attempt inside one in-flight window, so back off first.
580    InFlight,
581}
582
583/// Classify a lost race by the S3 error code, or `None` if it isn't one.
584fn race_kind(code: Option<&str>) -> Option<RaceKind> {
585    match code {
586        Some("ConditionalRequestConflict") => Some(RaceKind::InFlight),
587        Some("PreconditionFailed" | "NoSuchKey") => Some(RaceKind::Settled),
588        _ => None,
589    }
590}
591
592/// Consumer view of a raw read: a tombstone reads as absent (spec §3.2).
593fn visible(record: Option<Record>) -> Option<Record> {
594    record.filter(|r| !r.is_tombstone())
595}
596
597/// What a read said about a key the listing turned up.
598#[derive(Clone, Debug, PartialEq, Eq)]
599enum Liveness {
600    /// Listed.
601    Live,
602    /// Hidden. Carries the tombstone's revision, which is what its marker
603    /// names (ADR 0025), so a pass that finds one unmarked can write it.
604    Tombstoned(Revision),
605    /// Hidden: purged between the listing and the read.
606    Absent,
607}
608
609/// Whether consumer `list` includes a key, given the raw read of its object.
610/// Tombstones and keys purged since the listing (NotFound) are excluded. An
611/// object that fails to decode stays listed, so `get` on that key surfaces the
612/// error. Any other read error (network, 5xx, permission) fails the whole
613/// `list`: on s3 it may be transient, and listing a key that may be a
614/// tombstone would be wrong. fs and git differ: they keep every unreadable
615/// entry listed.
616fn listed_as_live(read: Result<Option<Record>>) -> Result<Liveness> {
617    match read {
618        Ok(Some(record)) if record.is_tombstone() => Ok(Liveness::Tombstoned(record.revision)),
619        Ok(Some(_)) => Ok(Liveness::Live),
620        Ok(None) => Ok(Liveness::Absent),
621        Err(CoreError::Serde(_)) => Ok(Liveness::Live),
622        Err(e) => Err(e),
623    }
624}
625
626/// One planner decision, translated into the S3 action that carries it out
627/// and the answer to return once that action lands.
628// `Record` is the natural payload for a write step; boxing it would ripple
629// through every `*_step` signature for no benefit at this call volume.
630#[allow(clippy::large_enum_variant)]
631#[derive(Debug, PartialEq, Eq)]
632enum Planned<T> {
633    /// No write needed; return this answer.
634    Finish(T),
635    /// `PutObject` this record under [`precondition`], then return the answer.
636    Put(Record, T),
637    /// `DeleteObject` with `If-Match` on the read ETag, then return the answer.
638    Remove(T),
639}
640
641/// Translate a [`PutPlan`] (from `plan_put` or `plan_put_raw`).
642fn put_step(key: &RecordKey, plan: PutPlan) -> Result<Planned<PutResult>> {
643    match plan {
644        PutPlan::Write(record) => {
645            let committed = PutResult::Committed(record.revision.clone());
646            Ok(Planned::Put(record, committed))
647        }
648        PutPlan::Conflict(conflict) => Ok(Planned::Finish(PutResult::Conflict(conflict))),
649        PutPlan::NotFound => Err(CoreError::NotFound(key.clone())),
650        // Only consumer `plan_put` produces this: for a `RecordKind::Tombstone`
651        // record (deletes go through `delete_as`, replication through `put_raw`)
652        // and for a create over a manifest tombstone.
653        PutPlan::Rejected(reason) => Err(CoreError::Invalid(reason.to_string())),
654    }
655}
656
657/// Translate a [`DeletePlan`]: a tombstone is written with `PutObject`.
658fn delete_step(plan: DeletePlan) -> Planned<DeleteResult> {
659    match plan {
660        DeletePlan::Write(tombstone) => Planned::Put(tombstone, DeleteResult::Deleted),
661        DeletePlan::Noop => Planned::Finish(DeleteResult::Deleted),
662        DeletePlan::Conflict(conflict) => Planned::Finish(DeleteResult::Conflict(conflict)),
663    }
664}
665
666/// Translate a [`PurgePlan`]: the only plan that physically removes an object.
667fn purge_step(plan: PurgePlan) -> Planned<DeleteResult> {
668    match plan {
669        PurgePlan::Remove => Planned::Remove(DeleteResult::Deleted),
670        PurgePlan::Noop => Planned::Finish(DeleteResult::Deleted),
671        PurgePlan::Conflict(conflict) => Planned::Finish(DeleteResult::Conflict(conflict)),
672    }
673}
674
675/// Most read → plan → conditional-write attempts one call makes before giving
676/// up on a key that other writers keep changing underneath it.
677const MAX_WRITE_ATTEMPTS: usize = 8;
678
679/// How many of consumer `list`'s per-key reads run at once. Hiding tombstones
680/// costs a `GetObject` per key (spec §8.4); doing them one at a time multiplied
681/// every key by a round trip. Bounded so a large collection cannot open an
682/// unbounded number of connections (gonzalo#286).
683const LIST_READ_CONCURRENCY: usize = 16;
684
685/// Result of one attempt: finished with an answer, or lost the race (412) and
686/// must re-read and re-plan.
687#[derive(Debug, PartialEq, Eq)]
688enum Step<T> {
689    Done(T),
690    Retry(RaceKind),
691}
692
693/// Run `attempt` until it finishes, at most [`MAX_WRITE_ATTEMPTS`] times. An
694/// error from an attempt ends the loop immediately. Each retry re-reads and
695/// re-plans, so the planner always decides against the true current record,
696/// just as it does under the fs and git stores' locks.
697async fn retry_on_lost_race<T, F, Fut>(key: &RecordKey, mut attempt: F) -> Result<T>
698where
699    F: FnMut() -> Fut,
700    Fut: std::future::Future<Output = Result<Step<T>>>,
701{
702    for round in 0..MAX_WRITE_ATTEMPTS {
703        match attempt().await? {
704            Step::Done(value) => return Ok(value),
705            // A settled race (412, or the object vanished to a purge) means the
706            // other writer has finished: re-read and re-plan immediately.
707            Step::Retry(RaceKind::Settled) => {}
708            // A 409 means a conflicting conditional write is still in flight.
709            // Retrying straight away can burn every attempt inside that one
710            // window, so wait a little, with jitter so racing writers don't
711            // line up again (gonzalo#286).
712            Step::Retry(RaceKind::InFlight) => {
713                tokio::time::sleep(inflight_backoff(round)).await;
714            }
715        }
716    }
717    Err(CoreError::Backend(format!(
718        "s3: conditional write for {key} lost {MAX_WRITE_ATTEMPTS} consecutive races"
719    )))
720}
721
722/// Whether the record a previous attempt sent is what the store now holds, so
723/// that attempt's write applied after all (gonzalo#286).
724///
725/// A conditional `PutObject` can apply on the server and still look like a
726/// failure: the response times out, the SDK retries, and the server answers
727/// `412` because the object already carries the new ETag. Re-planning against
728/// that state reports a `Conflict` for the caller's own committed write.
729/// Comparing whole records is exact — two different writes never produce the
730/// same record, because the revision hashes the body and a rewrite of the same
731/// body at the same revision is the same record.
732fn own_write_landed(sent: &Record, current: Option<&Record>) -> bool {
733    current.is_some_and(|rec| rec == sent)
734}
735
736/// How long to wait before retrying after a `409 ConditionalRequestConflict`:
737/// a doubling delay from ~4 ms, capped, with jitter so two writers that
738/// collided do not wake together and collide again (gonzalo#286).
739///
740/// The jitter is derived from the clock rather than a random-number generator,
741/// which would be a dependency for a few bits of entropy that nothing depends
742/// on for correctness: a bad draw only costs another attempt.
743fn inflight_backoff(round: usize) -> std::time::Duration {
744    const BASE_MS: u64 = 4;
745    const CAP_MS: u64 = 250;
746    let step = BASE_MS.saturating_mul(1 << round.min(6)).min(CAP_MS);
747    let jitter = std::time::SystemTime::now()
748        .duration_since(std::time::UNIX_EPOCH)
749        .map(|d| u64::from(d.subsec_nanos()) % step.max(1))
750        .unwrap_or(0);
751    std::time::Duration::from_millis(step + jitter)
752}
753
754/// Result of one conditional S3 write: it applied, or its precondition failed
755/// (412) because a concurrent writer changed the object after our read.
756#[derive(Debug, PartialEq, Eq)]
757enum WriteOutcome {
758    Applied,
759    /// The write did not apply because another writer got there first; the
760    /// kind says whether that writer has finished (gonzalo#286).
761    LostRace(RaceKind),
762}
763
764/// Decide the continuation token for the next `list_objects_v2` page, driving
765/// pagination off token *presence* rather than the `is_truncated` flag. A
766/// well-behaved backend only returns a token when there is more to fetch, but a
767/// misbehaving one can report `is_truncated = true` yet omit the token; keying
768/// off the flag would then re-request page 1 forever. So: if a token is present
769/// we continue with it, otherwise we terminate — regardless of `is_truncated`.
770/// This guarantees the pagination loop always makes progress or stops.
771fn next_continuation(_is_truncated: Option<bool>, token: Option<&str>) -> Option<String> {
772    token.map(str::to_string)
773}
774
775#[async_trait]
776impl gonzalo_core::Store for S3Store {
777    // ---- consumer surface (tombstones hidden) ----
778
779    async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
780        Ok(visible(self.read(key).await?))
781    }
782
783    async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
784        // A tombstone lives at the deleted record's own object key, so the
785        // listing cannot tell it from a live record by the record object alone.
786        // A marker beside it can (ADR 0025): one traversal classifies every
787        // key, and only marked keys are read — one read per *tombstone*, which
788        // `collect` bounds, instead of one per *record*, which nothing did.
789        let listing = self.list_objects(prefix).await?;
790
791        // Trust is earned per collection, because that is the granularity a
792        // pass can prove, so decide per collection. A pass spanning several
793        // enumerated each of them fully and may flag each of them.
794        let mut by_collection: BTreeMap<(String, String), Vec<RecordKey>> = BTreeMap::new();
795        for key in listing.records {
796            by_collection
797                .entry((key.namespace.clone(), key.collection.clone()))
798                .or_default()
799                .push(key);
800        }
801        // A collection can hold markers and no records at all — every record
802        // purged, one marker left behind by a purge that died between its two
803        // deletes. Grouping on records alone would never visit it, so the
804        // orphan would outlive every listing.
805        for marker in listing.markers.keys() {
806            by_collection
807                .entry((marker.namespace.clone(), marker.collection.clone()))
808                .or_default();
809        }
810
811        let mut out = Vec::new();
812        for ((namespace, collection), keys) in by_collection {
813            let marked = self.collection_marked(&namespace, &collection).await?;
814            // Fast path: read only what a marker points at. Slow path: read
815            // every key, exactly as before markers existed, and repair what the
816            // old layout left behind.
817            let to_read: Vec<RecordKey> = if marked {
818                keys.iter()
819                    .filter(|k| listing.markers.contains_key(k))
820                    .cloned()
821                    .collect()
822            } else {
823                keys.clone()
824            };
825            let live = self.read_liveness(&to_read).await?;
826
827            for key in &keys {
828                match live.get(key) {
829                    // Not read: unmarked in a flagged collection, so live.
830                    None => out.push(key.clone()),
831                    Some(Liveness::Live) => {
832                        if let Some(etag) = listing.markers.get(key) {
833                            // Live but marked — a marker left by a recreation,
834                            // or by a crash before the tombstone landed. Remove
835                            // it under the ETag this listing saw, so a delete
836                            // that has written a *new* marker since keeps it.
837                            self.delete_marker_if_match(key, etag).await?;
838                        }
839                        out.push(key.clone());
840                    }
841                    Some(Liveness::Tombstoned(revision)) => {
842                        if !listing.markers.contains_key(key) {
843                            // A tombstone the old layout left unmarked. The
844                            // pass that just proved it is the cheapest place to
845                            // repair it.
846                            self.put_marker(key, revision).await?;
847                        }
848                    }
849                    Some(Liveness::Absent) => {
850                        // Purged between the listing and the read, so its
851                        // marker — if any — is now an orphan.
852                        if let Some(etag) = listing.markers.get(key) {
853                            self.delete_marker_if_match(key, etag).await?;
854                        }
855                    }
856                }
857            }
858
859            // A marker whose record was not in the listing at all is an orphan
860            // left by a purge, which removes the record and never the marker.
861            let listed: BTreeSet<&RecordKey> = keys.iter().collect();
862            for (orphan, etag) in listing
863                .markers
864                .iter()
865                .filter(|(m, _)| m.namespace == namespace && m.collection == collection)
866                .filter(|(m, _)| !listed.contains(m))
867            {
868                self.delete_marker_if_match(orphan, etag).await?;
869            }
870
871            if !marked {
872                self.mark_collection(&namespace, &collection).await?;
873            }
874        }
875        // One deterministic order for the whole listing, so what a caller sees
876        // never depends on which read returned first.
877        out.sort();
878        Ok(out)
879    }
880
881    async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
882        // Consumer write: a tombstone counts as absent, so `expected = None`
883        // over one is a recreation that re-stamps the revision (`plan_put`).
884        let key = record.key.clone();
885        self.write_planned(&key, |current| {
886            put_step(
887                &key,
888                // The clock is read per attempt: a retry after a lost race is
889                // a later write, and its stamps should say so (gonzalo#293).
890                plan_put(
891                    current,
892                    record.clone(),
893                    expected.clone(),
894                    now_ms(),
895                    self.cap,
896                ),
897            )
898        })
899        .await
900    }
901
902    // `delete` is the trait's provided method: `delete_as(key, expected, None)`.
903    async fn delete_as(
904        &self,
905        key: &RecordKey,
906        expected: Option<Revision>,
907        author: Option<Identity>,
908    ) -> Result<DeleteResult> {
909        // Deletion writes a tombstone (spec §3.1). `now_ms()` is taken per
910        // attempt, so a retried delete stamps when it actually landed.
911        self.write_planned(key, |current| {
912            Ok(delete_step(plan_delete(
913                current,
914                expected.clone(),
915                now_ms(),
916                self.cap,
917                author.as_ref(),
918            )))
919        })
920        .await
921    }
922
923    // ---- replication surface (tombstones visible) ----
924
925    async fn get_raw(&self, key: &RecordKey) -> Result<Option<Record>> {
926        self.read(key).await
927    }
928
929    async fn list_raw(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
930        self.list_keys(prefix).await
931    }
932
933    async fn put_raw(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
934        // Replication write: stores the caller's revision verbatim, never
935        // re-stamps, and a tombstone is a real current record it must name in
936        // `expected` (`plan_put_raw`).
937        let key = record.key.clone();
938        self.write_planned(&key, |current| {
939            put_step(
940                &key,
941                plan_put_raw(
942                    current,
943                    record.clone(),
944                    expected.clone(),
945                    now_ms(),
946                    self.cap,
947                ),
948            )
949        })
950        .await
951    }
952
953    async fn purge(&self, key: &RecordKey, expected: Revision) -> Result<DeleteResult> {
954        // The only physical removal: a conditional DeleteObject, and only when
955        // the stored revision is still `expected`, so a recreation that lands
956        // mid-purge survives.
957        self.write_planned(key, |current| {
958            Ok(purge_step(plan_purge(current, &expected)))
959        })
960        .await
961    }
962}
963
964#[async_trait]
965impl BlobStore for S3Store {
966    async fn put_blob(&self, content: &[u8]) -> Result<ContentHash> {
967        let hash = ContentHash::of(content);
968        let key = format!("{BLOB_PREFIX}{}", hash.0);
969        // Content-addressed + write-if-absent: an existing blob at this key is
970        // byte-identical, so `If-None-Match: *` turns a re-upload into a no-op
971        // (a 412 just means it's already stored). Idempotent and bandwidth-cheap.
972        match self
973            .client
974            .put_object()
975            .bucket(&self.bucket)
976            .key(&key)
977            .if_none_match("*")
978            .body(content.to_vec().into())
979            .send()
980            .await
981        {
982            Ok(_) => Ok(hash),
983            Err(e) => {
984                let svc = e.into_service_error();
985                if is_precondition_failed(svc.code()) {
986                    Ok(hash) // already present — no-op
987                } else {
988                    Err(CoreError::Backend(svc.to_string()))
989                }
990            }
991        }
992    }
993
994    async fn get_blob(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
995        let key = format!("{BLOB_PREFIX}{}", hash.0);
996        match self
997            .client
998            .get_object()
999            .bucket(&self.bucket)
1000            .key(&key)
1001            .send()
1002            .await
1003        {
1004            Ok(resp) => {
1005                let data = resp
1006                    .body
1007                    .collect()
1008                    .await
1009                    .map_err(|e| CoreError::Backend(e.to_string()))?
1010                    .into_bytes();
1011                Ok(Some(data.to_vec()))
1012            }
1013            Err(e) => {
1014                let svc = e.into_service_error();
1015                if svc.is_no_such_key() {
1016                    Ok(None)
1017                } else {
1018                    Err(CoreError::Backend(svc.to_string()))
1019                }
1020            }
1021        }
1022    }
1023
1024    async fn list_blobs(&self) -> Result<Vec<BlobEntry>> {
1025        let mut out = Vec::new();
1026        let mut continuation: Option<String> = None;
1027        loop {
1028            let mut req = self
1029                .client
1030                .list_objects_v2()
1031                .bucket(&self.bucket)
1032                .prefix(BLOB_PREFIX);
1033            if let Some(token) = &continuation {
1034                req = req.continuation_token(token);
1035            }
1036            let resp = req
1037                .send()
1038                .await
1039                .map_err(|e| CoreError::Backend(e.into_service_error().to_string()))?;
1040            for obj in resp.contents() {
1041                if let Some(k) = obj.key()
1042                    && let Some(hash) = blob_hash_from_key(k)
1043                {
1044                    out.push(blob_entry_from_listing(k, hash, obj.last_modified())?);
1045                }
1046            }
1047            match next_continuation(resp.is_truncated(), resp.next_continuation_token()) {
1048                Some(token) => continuation = Some(token),
1049                None => break,
1050            }
1051        }
1052        Ok(out)
1053    }
1054
1055    async fn delete_blob(&self, hash: &ContentHash) -> Result<()> {
1056        let key = format!("{BLOB_PREFIX}{}", hash.0);
1057        self.client
1058            .delete_object()
1059            .bucket(&self.bucket)
1060            .key(&key)
1061            .send()
1062            .await
1063            .map_err(|e| CoreError::Backend(e.into_service_error().to_string()))?;
1064        Ok(()) // S3 delete of an absent key succeeds — idempotent
1065    }
1066
1067    async fn has_blob(&self, hash: &ContentHash) -> Result<bool> {
1068        let key = format!("{BLOB_PREFIX}{}", hash.0);
1069        match self
1070            .client
1071            .head_object()
1072            .bucket(&self.bucket)
1073            .key(&key)
1074            .send()
1075            .await
1076        {
1077            Ok(_) => Ok(true),
1078            Err(e) => {
1079                // Read the HTTP status before `into_service_error` drops the raw
1080                // response; it is the most diagnosable part of a 403 (#330).
1081                let status = e.raw_response().map(|r| r.status().as_u16());
1082                let svc = e.into_service_error();
1083                if head_error_is_absent(&svc) {
1084                    Ok(false)
1085                } else {
1086                    Err(CoreError::Backend(format!(
1087                        "head blob {}: {}",
1088                        hash.0,
1089                        describe_head_error(&svc, status)
1090                    )))
1091                }
1092            }
1093        }
1094    }
1095}
1096
1097/// Whether a `HeadObject` error code means "no such blob" as opposed to a fault
1098/// worth surfacing. S3 answers a missing key with 404 `NotFound`; anything else
1099/// is a real error and must not be reported as absence.
1100fn head_means_absent(code: &str) -> bool {
1101    matches!(code, "NotFound" | "NoSuchKey")
1102}
1103
1104/// Whether a `HeadObject` service error means "no such blob".
1105///
1106/// Split out of [`S3Store::has_blob`] so the *combination* of the two checks is
1107/// testable, not just the string half (gonzalo#330). The earlier unit test only
1108/// covered [`head_means_absent`], so an edit that mapped `AccessDenied` or a 500
1109/// to absence would have passed the suite untouched.
1110///
1111/// Getting this wrong is asymmetric. Reporting a present blob as absent only
1112/// makes a writer re-upload it, never delete it. Reporting a *fault* as absence
1113/// is worse: it hides an outage behind a plausible answer, and a GC run that
1114/// believes a blob is gone will not re-pin it.
1115fn head_error_is_absent(e: &HeadObjectError) -> bool {
1116    e.is_not_found() || head_means_absent(e.code().unwrap_or_default())
1117}
1118
1119/// A `HeadObject` failure, described for whoever reads the log.
1120///
1121/// `HeadObjectError`'s own `Display` renders the `Unhandled` variant as
1122/// "unhandled error", which tells an operator nothing about a 403 (gonzalo#330).
1123/// Lead with the service's error code and the HTTP status, so the common
1124/// misconfigurations — wrong credentials, wrong bucket policy — are readable
1125/// from the message alone.
1126fn describe_head_error(e: &HeadObjectError, status: Option<u16>) -> String {
1127    let code = e.code().unwrap_or("unknown code");
1128    let mut out = match status {
1129        Some(status) => format!("{code} (HTTP {status})"),
1130        None => code.to_string(),
1131    };
1132    match e.message() {
1133        Some(message) => {
1134            out.push_str(": ");
1135            out.push_str(message);
1136        }
1137        // No service message: fall back to the SDK's own rendering rather than
1138        // stopping at a bare code.
1139        None => out.push_str(&format!(": {e}")),
1140    }
1141    out
1142}
1143
1144/// Map one listed blob object to a [`BlobEntry`].
1145///
1146/// Split out of `list_blobs` so its two failure branches are reachable from a
1147/// unit test (gonzalo#330): the crate has no fake S3 client and its integration
1148/// tests need a live endpoint, so otherwise only CI's HA soak exercises this code
1149/// at all. Both branches fail the whole listing loudly, which is right — a
1150/// listing we cannot date is not one GC should sweep from. Only the
1151/// missing-timestamp branch is testable; see the note on the conversion below.
1152fn blob_entry_from_listing(
1153    key: &str,
1154    hash: ContentHash,
1155    last_modified: Option<&DateTime>,
1156) -> Result<BlobEntry> {
1157    // ListObjectsV2 always carries LastModified; a response without one is a
1158    // malformed listing, not a blob we can reason about the age of.
1159    let dt = last_modified
1160        .ok_or_else(|| CoreError::Backend(format!("blob {key} listed without a LastModified")))?;
1161    // Unreachable on a 64-bit target, and deliberately kept anyway.
1162    // `SystemTime::try_from(DateTime)` fails only if the offset from the epoch
1163    // overflows `SystemTime`, and on a 64-bit `timespec` platform both extremes
1164    // of the `i64` seconds a `DateTime` can hold convert cleanly -- measured on
1165    // the Linux builder: `i64::MAX` yields an entry dated `i64::MAX` ms and
1166    // `i64::MIN` one dated `-i64::MAX` ms, neither an error. So no unit test
1167    // covers this arm; a 32-bit target is where it earns its keep (gonzalo#330).
1168    let modified = SystemTime::try_from(*dt).map_err(|e| {
1169        CoreError::Backend(format!(
1170            "blob {key} has an unrepresentable LastModified: {e}"
1171        ))
1172    })?;
1173    Ok(BlobEntry::from_system_time(hash, modified))
1174}
1175
1176/// Parse a blob object key `blobs/<hash>` back into a [`ContentHash`]. Returns
1177/// `None` for anything that isn't exactly one segment under `blobs/` — so a
1178/// record object that happens to live in a `blobs` namespace
1179/// (`blobs/<col>/<id>.json`, which still has a `/`) is never mistaken for a blob.
1180fn blob_hash_from_key(key: &str) -> Option<ContentHash> {
1181    let rest = key.strip_prefix(BLOB_PREFIX)?;
1182    if rest.is_empty() || rest.contains('/') || rest.contains('.') {
1183        return None;
1184    }
1185    Some(ContentHash(rest.to_string()))
1186}
1187
1188/// Parse `namespace/collection/id.json` back into a `RecordKey`, decoding each
1189/// component (the exact inverse of `object_key`). Returns `None` for objects
1190/// that don't match the expected three-part `.json` shape. Since every literal
1191/// `/` in a component is escaped, splitting on `/` always yields exactly the
1192/// three separators' worth of parts.
1193fn parse_object_key(s: &str) -> Option<RecordKey> {
1194    let rest = s.strip_suffix(".json")?;
1195    let parts: Vec<&str> = rest.split('/').collect();
1196    if parts.len() == 3 {
1197        Some(RecordKey::new(
1198            decode_segment(parts[0]),
1199            decode_segment(parts[1]),
1200            decode_segment(parts[2]),
1201        ))
1202    } else {
1203        None
1204    }
1205}
1206
1207/// Suffix appended to a record's object key to form its tombstone marker
1208/// (ADR 0025). `segment()` escapes `.`, so no encoded id can end in this and
1209/// the two key spaces are disjoint by construction.
1210const MARKER_SUFFIX: &str = ".tombstone";
1211
1212/// The marker object key for `key`: `namespace/collection/id.json.tombstone`.
1213///
1214/// An object here means "the record at this key may be a tombstone —
1215/// read it to find out". Its *absence* is only meaningful in a collection
1216/// carrying [`marked_flag_key`], since a bucket written before ADR 0025 has
1217/// tombstones with no marker at all.
1218fn marker_key(key: &RecordKey) -> String {
1219    format!("{}{MARKER_SUFFIX}", object_key(key))
1220}
1221
1222/// The record a marker object belongs to, or `None` if `s` is not a marker.
1223fn parse_marker_key(s: &str) -> Option<RecordKey> {
1224    parse_object_key(s.strip_suffix(MARKER_SUFFIX)?)
1225}
1226
1227/// The per-collection flag object key. Its presence says every tombstone in
1228/// this collection carries a marker, so `list` may treat an unmarked key as
1229/// live. The name holds no `.` and does not end in `.json`, so
1230/// [`parse_object_key`] can never produce it from a caller's record key.
1231fn marked_flag_key(namespace: &str, collection: &str) -> String {
1232    format!(
1233        "{}/{}/_tombstone_markers",
1234        gonzalo_core::segment(namespace),
1235        gonzalo_core::segment(collection)
1236    )
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    // Review Focus 5. A 403, a 500 or throttling must surface as Err. Reporting
1244    // `false` would make a writer re-upload on every transient fault and hide a
1245    // broken store behind what looks like a missing blob.
1246    #[test]
1247    fn a_head_error_other_than_not_found_is_not_absence() {
1248        assert!(!head_means_absent("AccessDenied"));
1249        assert!(!head_means_absent("InternalError"));
1250        assert!(head_means_absent("NotFound"));
1251    }
1252
1253    /// A `HeadObjectError` carrying `code`, as the SDK builds an unmodelled
1254    /// service error. `generic` is the sanctioned constructor; matching the
1255    /// `Unhandled` variant directly is deprecated.
1256    fn head_error(code: &str) -> HeadObjectError {
1257        HeadObjectError::generic(
1258            aws_sdk_s3::error::ErrorMetadata::builder()
1259                .code(code)
1260                .message("from the service")
1261                .build(),
1262        )
1263    }
1264
1265    // gonzalo#330: the test above only covers the string half of `has_blob`'s
1266    // decision. This one covers the predicate `has_blob` actually calls, so an
1267    // edit that mapped a fault to `Ok(false)` fails here.
1268    #[test]
1269    fn only_a_not_found_head_error_is_absence() {
1270        // The modelled 404, which `is_not_found()` answers.
1271        assert!(head_error_is_absent(&HeadObjectError::NotFound(
1272            aws_sdk_s3::types::error::NotFound::builder().build()
1273        )));
1274        // An unmodelled error whose code still means absence, which only the
1275        // `head_means_absent` arm catches.
1276        assert!(head_error_is_absent(&head_error("NoSuchKey")));
1277
1278        // Faults. None of these may be reported as absence.
1279        for code in ["AccessDenied", "InternalError", "SlowDown", ""] {
1280            assert!(
1281                !head_error_is_absent(&head_error(code)),
1282                "code {code:?} must not read as absence"
1283            );
1284        }
1285    }
1286
1287    // gonzalo#330: `HeadObjectError`'s own Display renders an unmodelled error
1288    // as "unhandled error", which does not tell an operator a 403 happened.
1289    #[test]
1290    fn a_head_fault_is_described_with_its_code_and_status() {
1291        let msg = describe_head_error(&head_error("AccessDenied"), Some(403));
1292        assert!(msg.contains("AccessDenied"), "{msg}");
1293        assert!(msg.contains("403"), "{msg}");
1294        assert!(msg.contains("from the service"), "{msg}");
1295
1296        // No status available (a response error, not a service error).
1297        let msg = describe_head_error(&head_error("InternalError"), None);
1298        assert!(msg.contains("InternalError"), "{msg}");
1299        assert!(!msg.contains("HTTP"), "no status should be invented: {msg}");
1300    }
1301
1302    // gonzalo#330: `list_blobs`'s two failure branches. Neither was reachable in
1303    // a unit test before, and only CI's HA soak touches `list_blobs` at all.
1304    #[test]
1305    fn a_listed_blob_without_a_last_modified_fails_the_listing() {
1306        let err = blob_entry_from_listing("blobs/abc", ContentHash("abc".into()), None)
1307            .expect_err("a listing we cannot date must not be silently dropped");
1308        let msg = err.to_string();
1309        assert!(msg.contains("blobs/abc"), "{msg}");
1310        assert!(msg.contains("without a LastModified"), "{msg}");
1311    }
1312
1313    #[test]
1314    fn a_well_formed_listed_blob_maps_to_an_entry() {
1315        let dt = DateTime::from_secs(1_700_000_000);
1316        let entry = blob_entry_from_listing("blobs/ok", ContentHash("ok".into()), Some(&dt))
1317            .expect("a dated blob maps cleanly");
1318        assert_eq!(entry.hash, ContentHash("ok".into()));
1319    }
1320
1321    #[test]
1322    fn marker_key_is_the_record_key_plus_a_suffix() {
1323        let k = RecordKey::new("ns", "col", "id");
1324        assert_eq!(marker_key(&k), "ns/col/id.json.tombstone");
1325        assert_eq!(parse_marker_key(&marker_key(&k)), Some(k));
1326    }
1327
1328    #[test]
1329    fn a_marker_key_is_not_a_record_key() {
1330        // `segment()` escapes `.`, so no id can produce a key ending in
1331        // `.json.tombstone` — the two key spaces are disjoint by construction.
1332        let k = RecordKey::new("ns", "col", "id");
1333        assert_eq!(parse_object_key(&marker_key(&k)), None);
1334        for id in ["id.json.tombstone", "id.json", "a.b", "..", "50%"] {
1335            let key = RecordKey::new("ns", "col", id);
1336            assert_ne!(object_key(&key), marker_key(&k));
1337            assert_eq!(parse_marker_key(&object_key(&key)), None);
1338        }
1339    }
1340
1341    #[test]
1342    fn marked_flag_key_is_not_a_record_or_marker_key() {
1343        let flag = marked_flag_key("ns", "col");
1344        assert_eq!(flag, "ns/col/_tombstone_markers");
1345        assert_eq!(parse_object_key(&flag), None);
1346        assert_eq!(parse_marker_key(&flag), None);
1347    }
1348
1349    #[test]
1350    fn marked_flag_key_escapes_its_components() {
1351        // Same encoding as record keys, so a namespace containing `/` cannot
1352        // reach another collection's flag.
1353        assert_eq!(
1354            marked_flag_key("a/b", "c.d"),
1355            "a%2Fb/c%2Ed/_tombstone_markers"
1356        );
1357    }
1358
1359    #[test]
1360    fn parse_roundtrips_object_key() {
1361        let k = RecordKey::new("ns", "col", "id");
1362        assert_eq!(parse_object_key(&object_key(&k)), Some(k));
1363    }
1364
1365    #[test]
1366    fn parse_roundtrips_special_char_keys() {
1367        // Keys with `.`, `/`, spaces, and `%` must survive the object-key
1368        // round-trip and stay distinct (no collision onto one object).
1369        for k in [
1370            RecordKey::new("a/b", "c.d", "e/f"),
1371            RecordKey::new("ns", "col", "v1.0"),
1372            RecordKey::new("ns", "col", "v1_0"),
1373            RecordKey::new("50% off", "café", "🚀"),
1374        ] {
1375            assert_eq!(parse_object_key(&object_key(&k)), Some(k));
1376        }
1377        assert_ne!(
1378            object_key(&RecordKey::new("ns", "col", "v1.0")),
1379            object_key(&RecordKey::new("ns", "col", "v1_0")),
1380        );
1381    }
1382
1383    #[test]
1384    fn parse_rejects_non_json_or_wrong_depth() {
1385        assert_eq!(parse_object_key("a/b/c.txt"), None);
1386        assert_eq!(parse_object_key("a/b.json"), None);
1387        assert_eq!(parse_object_key("a/b/c/d.json"), None);
1388    }
1389
1390    use gonzalo_core::store::Conflict;
1391    use gonzalo_core::{Body, Identity, Meta, RecordKind, plan_put, tombstone_of};
1392    use std::cell::Cell;
1393    use std::collections::BTreeMap;
1394
1395    fn live(key: &RecordKey, payload: &[u8]) -> Record {
1396        Record {
1397            key: key.clone(),
1398            kind: RecordKind::Topic,
1399            revision: Revision::initial(payload),
1400            parent: None,
1401            body: Body::Inline(payload.to_vec()),
1402            meta: Meta {
1403                author: Identity::new("tester"),
1404                origin_system: "test".into(),
1405                created: 0,
1406                updated: 0,
1407                labels: BTreeMap::new(),
1408            },
1409            links: Vec::new(),
1410            ancestors: Vec::new(),
1411            deleted_at: None,
1412            deleted_blob: None,
1413            deleted_kind: None,
1414        }
1415    }
1416
1417    fn tomb(key: &RecordKey, payload: &[u8]) -> Record {
1418        tombstone_of(&live(key, payload), 1_000, DEFAULT_ANCESTOR_CAP, None)
1419    }
1420
1421    // ---- hardening before other S3 backends are qualified (#286) ----
1422
1423    #[test]
1424    fn an_ambiguous_commit_is_recognised_as_our_own_write() {
1425        // The write applied, the response timed out, the retry saw 412. The
1426        // re-read returns exactly what we sent, so the attempt succeeded.
1427        let k = RecordKey::new("ns", "col", "ambiguous");
1428        let sent = live(&k, b"v1");
1429        assert!(own_write_landed(&sent, Some(&sent)));
1430
1431        // Someone else's write, or nothing at all, is not ours.
1432        assert!(!own_write_landed(&sent, Some(&live(&k, b"someone-else"))));
1433        assert!(!own_write_landed(&sent, Some(&tomb(&k, b"v1"))));
1434        assert!(!own_write_landed(&sent, None));
1435    }
1436
1437    #[test]
1438    fn inflight_backoff_grows_and_stays_bounded() {
1439        // Each round waits at least as long as the one before, and never more
1440        // than twice the cap, so eight attempts cannot stall a caller.
1441        let mut previous = std::time::Duration::ZERO;
1442        for round in 0..MAX_WRITE_ATTEMPTS {
1443            let wait = inflight_backoff(round);
1444            assert!(wait >= std::time::Duration::from_millis(4), "round {round}");
1445            assert!(
1446                wait < std::time::Duration::from_millis(500),
1447                "round {round}"
1448            );
1449            if round > 0 {
1450                assert!(
1451                    wait >= previous / 2,
1452                    "round {round} fell far below the previous wait"
1453                );
1454            }
1455            previous = wait;
1456        }
1457    }
1458
1459    fn conflict(key: &RecordKey) -> Box<Conflict> {
1460        Box::new(Conflict {
1461            key: key.clone(),
1462            expected: Some(Revision::initial(b"stale")),
1463            current: live(key, b"current"),
1464        })
1465    }
1466
1467    /// An `S3Store` that never touches the network: building a client does no
1468    /// I/O, so the builder can be unit-tested without an endpoint.
1469    fn offline_store() -> S3Store {
1470        let conf = aws_sdk_s3::Config::builder()
1471            .behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
1472            .region(aws_sdk_s3::config::Region::new("us-east-1"))
1473            .build();
1474        S3Store::new(Client::from_conf(conf), "offline")
1475    }
1476
1477    // ---- preconditions ----
1478
1479    #[test]
1480    fn create_uses_if_absent() {
1481        // No object was read → create-only.
1482        assert_eq!(precondition(None), Precondition::IfAbsent);
1483    }
1484
1485    #[test]
1486    fn update_uses_if_match_on_the_read_etag() {
1487        assert_eq!(
1488            precondition(Some("\"abc123\"")),
1489            Precondition::IfMatch("\"abc123\"".to_string())
1490        );
1491    }
1492
1493    #[test]
1494    fn recreation_over_tombstone_uses_if_match() {
1495        // `put(record, None)` over a tombstone is a recreation the planner
1496        // writes. The tombstone object exists, so the write must be `If-Match`
1497        // on its ETag. `If-None-Match: *` would 412 on every attempt.
1498        let k = RecordKey::new("ns", "col", "recreate");
1499        let plan = plan_put(
1500            Some(&tomb(&k, b"old")),
1501            live(&k, b"new"),
1502            None,
1503            now_ms(),
1504            DEFAULT_ANCESTOR_CAP,
1505        );
1506        assert!(matches!(plan, PutPlan::Write(_)));
1507        assert_eq!(
1508            precondition(Some("\"tomb-etag\"")),
1509            Precondition::IfMatch("\"tomb-etag\"".to_string())
1510        );
1511    }
1512
1513    // ---- consumer read filtering ----
1514
1515    #[test]
1516    fn visible_hides_tombstones_and_keeps_live() {
1517        let k = RecordKey::new("ns", "col", "vis");
1518        assert_eq!(visible(None), None);
1519        assert_eq!(visible(Some(tomb(&k, b"x"))), None);
1520        let rec = live(&k, b"x");
1521        assert_eq!(visible(Some(rec.clone())), Some(rec));
1522    }
1523
1524    #[test]
1525    fn list_filter_excludes_tombstones_and_vanished_keys() {
1526        let k = RecordKey::new("ns", "col", "listed");
1527        assert_eq!(
1528            listed_as_live(Ok(Some(live(&k, b"x")))).unwrap(),
1529            Liveness::Live
1530        );
1531        // A tombstone reports the revision its marker names, so a listing that
1532        // finds one unmarked can repair it without a second read.
1533        let t = tomb(&k, b"x");
1534        assert_eq!(
1535            listed_as_live(Ok(Some(t.clone()))).unwrap(),
1536            Liveness::Tombstoned(t.revision)
1537        );
1538        // Purged between the listing and the read.
1539        assert_eq!(listed_as_live(Ok(None)).unwrap(), Liveness::Absent);
1540    }
1541
1542    #[test]
1543    fn list_filter_keeps_undecodable_objects_and_propagates_backend_errors() {
1544        // An object that fails to decode stays listed; `get` surfaces the error.
1545        // Any other read error fails the list (s3 rule; fs and git keep such
1546        // entries listed).
1547        assert_eq!(
1548            listed_as_live(Err(CoreError::Serde("bad json".into()))).unwrap(),
1549            Liveness::Live
1550        );
1551        assert!(matches!(
1552            listed_as_live(Err(CoreError::Backend("503".into()))),
1553            Err(CoreError::Backend(_))
1554        ));
1555    }
1556
1557    // ---- plan → S3 action ----
1558
1559    #[test]
1560    fn put_step_writes_and_commits_the_planned_revision() {
1561        let k = RecordKey::new("ns", "col", "put-write");
1562        let rec = live(&k, b"v");
1563        match put_step(&k, PutPlan::Write(rec.clone())) {
1564            Ok(Planned::Put(stored, answer)) => {
1565                assert_eq!(answer, PutResult::Committed(rec.revision.clone()));
1566                assert_eq!(stored, rec);
1567            }
1568            other => panic!("expected Put, got {other:?}"),
1569        }
1570    }
1571
1572    #[test]
1573    fn put_step_finishes_on_conflict() {
1574        let k = RecordKey::new("ns", "col", "put-conflict");
1575        match put_step(&k, PutPlan::Conflict(conflict(&k))) {
1576            Ok(Planned::Finish(PutResult::Conflict(c))) => assert_eq!(c, conflict(&k)),
1577            other => panic!("expected Finish(Conflict), got {other:?}"),
1578        }
1579    }
1580
1581    #[test]
1582    fn put_step_maps_not_found_to_error() {
1583        let k = RecordKey::new("ns", "col", "put-missing");
1584        match put_step(&k, PutPlan::NotFound) {
1585            Err(CoreError::NotFound(got)) => assert_eq!(got, k),
1586            other => panic!("expected NotFound, got {other:?}"),
1587        }
1588    }
1589
1590    #[test]
1591    fn put_step_maps_rejected_to_an_invalid_call() {
1592        // `Invalid`, not `Backend`: the call can never succeed, and a daemon in
1593        // front of this store answers 400 rather than 500 (#299).
1594        let k = RecordKey::new("ns", "col", "put-rejected");
1595        let reason = gonzalo_core::CONSUMER_TOMBSTONE_REJECTED;
1596        match put_step(&k, PutPlan::Rejected(reason)) {
1597            Err(CoreError::Invalid(msg)) => assert_eq!(msg, reason),
1598            other => panic!("expected Invalid, got {other:?}"),
1599        }
1600    }
1601
1602    #[test]
1603    fn delete_step_maps_each_plan() {
1604        let k = RecordKey::new("ns", "col", "del");
1605        let t = tomb(&k, b"x");
1606        assert_eq!(
1607            delete_step(DeletePlan::Write(t.clone())),
1608            Planned::Put(t, DeleteResult::Deleted)
1609        );
1610        assert_eq!(
1611            delete_step(DeletePlan::Noop),
1612            Planned::Finish(DeleteResult::Deleted)
1613        );
1614        assert_eq!(
1615            delete_step(DeletePlan::Conflict(conflict(&k))),
1616            Planned::Finish(DeleteResult::Conflict(conflict(&k)))
1617        );
1618    }
1619
1620    #[test]
1621    fn purge_step_maps_each_plan() {
1622        let k = RecordKey::new("ns", "col", "purge");
1623        assert_eq!(
1624            purge_step(PurgePlan::Remove),
1625            Planned::Remove(DeleteResult::Deleted)
1626        );
1627        assert_eq!(
1628            purge_step(PurgePlan::Noop),
1629            Planned::Finish(DeleteResult::Deleted)
1630        );
1631        assert_eq!(
1632            purge_step(PurgePlan::Conflict(conflict(&k))),
1633            Planned::Finish(DeleteResult::Conflict(conflict(&k)))
1634        );
1635    }
1636
1637    // ---- lost-race classification ----
1638
1639    #[test]
1640    fn lost_race_covers_412_409_and_vanished_objects() {
1641        // 409 is the one that must back off; the others are settled races
1642        // the loop retries immediately (#286).
1643        assert_eq!(
1644            race_kind(Some("ConditionalRequestConflict")),
1645            Some(RaceKind::InFlight)
1646        );
1647        assert_eq!(
1648            race_kind(Some("PreconditionFailed")),
1649            Some(RaceKind::Settled)
1650        );
1651        assert_eq!(race_kind(Some("NoSuchKey")), Some(RaceKind::Settled));
1652        assert_eq!(race_kind(Some("AccessDenied")), None);
1653        assert_eq!(race_kind(None), None);
1654    }
1655
1656    // ---- lost-race retry loop ----
1657
1658    #[tokio::test]
1659    async fn retry_gives_up_after_max_attempts() {
1660        let k = RecordKey::new("ns", "col", "hot");
1661        let calls = Cell::new(0usize);
1662        let calls_ref = &calls;
1663        let out: Result<()> = retry_on_lost_race(&k, move || async move {
1664            calls_ref.set(calls_ref.get() + 1);
1665            Ok(Step::Retry(RaceKind::Settled))
1666        })
1667        .await;
1668        assert_eq!(calls.get(), MAX_WRITE_ATTEMPTS);
1669        assert_eq!(MAX_WRITE_ATTEMPTS, 8);
1670        match out {
1671            Err(CoreError::Backend(msg)) => assert_eq!(
1672                msg,
1673                format!("s3: conditional write for {k} lost 8 consecutive races")
1674            ),
1675            other => panic!("expected Backend error, got {other:?}"),
1676        }
1677    }
1678
1679    #[tokio::test]
1680    async fn retry_returns_as_soon_as_an_attempt_lands() {
1681        // Losing every attempt but the last still succeeds, with no extra call.
1682        let k = RecordKey::new("ns", "col", "warm");
1683        let calls = Cell::new(0usize);
1684        let calls_ref = &calls;
1685        let out = retry_on_lost_race(&k, move || async move {
1686            calls_ref.set(calls_ref.get() + 1);
1687            if calls_ref.get() < MAX_WRITE_ATTEMPTS {
1688                Ok(Step::Retry(RaceKind::Settled))
1689            } else {
1690                Ok(Step::Done("landed"))
1691            }
1692        })
1693        .await;
1694        assert_eq!(out.unwrap(), "landed");
1695        assert_eq!(calls.get(), MAX_WRITE_ATTEMPTS);
1696    }
1697
1698    #[tokio::test]
1699    async fn retry_propagates_errors_without_retrying() {
1700        let k = RecordKey::new("ns", "col", "broken");
1701        let calls = Cell::new(0usize);
1702        let calls_ref = &calls;
1703        let out: Result<()> = retry_on_lost_race(&k, move || async move {
1704            calls_ref.set(calls_ref.get() + 1);
1705            Err(CoreError::Backend("access denied".into()))
1706        })
1707        .await;
1708        assert!(matches!(out, Err(CoreError::Backend(m)) if m == "access denied"));
1709        assert_eq!(calls.get(), 1);
1710    }
1711
1712    // ---- ancestor cap ----
1713
1714    #[tokio::test]
1715    async fn new_store_uses_default_ancestor_cap() {
1716        assert_eq!(offline_store().cap, DEFAULT_ANCESTOR_CAP);
1717    }
1718
1719    #[tokio::test]
1720    async fn with_ancestor_cap_sets_cap() {
1721        match offline_store().with_ancestor_cap(3) {
1722            Ok(store) => assert_eq!(store.cap, 3),
1723            Err(e) => panic!("cap 3 must be accepted: {e}"),
1724        }
1725    }
1726
1727    #[tokio::test]
1728    async fn with_ancestor_cap_rejects_zero() {
1729        assert!(matches!(
1730            offline_store().with_ancestor_cap(0),
1731            Err(CoreError::Backend(_))
1732        ));
1733    }
1734
1735    #[test]
1736    fn next_continuation_terminates_when_token_absent() {
1737        // The pagination bug: a backend reports more pages but omits the token.
1738        // Keying off `is_truncated` would loop forever; we must terminate.
1739        assert_eq!(next_continuation(Some(true), None), None);
1740        // No token, not truncated → also terminate (the normal last page).
1741        assert_eq!(next_continuation(Some(false), None), None);
1742        assert_eq!(next_continuation(None, None), None);
1743    }
1744
1745    #[test]
1746    fn next_continuation_advances_when_token_present() {
1747        // A token means fetch the next page, regardless of the flag's value.
1748        assert_eq!(
1749            next_continuation(Some(true), Some("t1")),
1750            Some("t1".to_string())
1751        );
1752        assert_eq!(
1753            next_continuation(Some(false), Some("t2")),
1754            Some("t2".to_string())
1755        );
1756        assert_eq!(next_continuation(None, Some("t3")), Some("t3".to_string()));
1757    }
1758
1759    #[test]
1760    fn precondition_failed_is_classified_by_code() {
1761        assert!(is_precondition_failed(Some("PreconditionFailed")));
1762        assert!(!is_precondition_failed(Some("AccessDenied")));
1763        assert!(!is_precondition_failed(None));
1764    }
1765
1766    #[test]
1767    fn blob_key_roundtrips_and_rejects_records() {
1768        let h = ContentHash::of(b"slice bytes");
1769        let key = format!("{BLOB_PREFIX}{}", h.0);
1770        assert_eq!(blob_hash_from_key(&key), Some(h));
1771        // A record object under a `blobs` namespace has a nested path + `.json`
1772        // and must never be read back as a blob hash.
1773        assert_eq!(blob_hash_from_key("blobs/col/id.json"), None);
1774        assert_eq!(blob_hash_from_key("ns/col/id.json"), None);
1775        assert_eq!(blob_hash_from_key("blobs/"), None);
1776    }
1777}