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}