Skip to main content

objectstore_service/backend/
tiered.rs

1//! Two-tier storage backend with size-based routing and redirect tombstones.
2//!
3//! [`TieredStorage`] routes objects to a high-volume or long-term backend based
4//! on size and maintains redirect tombstones so that reads never need to probe
5//! both backends. See the [crate-level documentation](crate) for the high-level
6//! motivation, and the [`TieredStorage`] struct docs for routing and tombstone
7//! semantics.
8//!
9//! # Cross-Tier Consistency
10//!
11//! A single logical object may span both backends: a tombstone in HV pointing
12//! to a payload in LT. Mutations keep the two in sync through compare-and-swap
13//! on the high-volume backend (see [`HighVolumeBackend::compare_and_write`]).
14//! Each operation reads the current HV revision, performs its work, then
15//! atomically swaps the HV entry only if the revision is still current —
16//! rolling back on conflict.
17//!
18//! ## Revision Keys
19//!
20//! Every large-object write stores its payload at a **revision key** in the
21//! long-term backend: `{original_key}/{uuid}`. The UUID suffix is random (no
22//! monotonicity is guaranteed), so each write targets a distinct LT path
23//! regardless of whether another write to the same logical key is in progress.
24//! The tombstone in HV then points to this specific revision. Because each
25//! writer owns its own LT blob, the compare-and-swap on the tombstone becomes
26//! an atomic pointer swap: the winner's revision is committed and the loser
27//! can safely delete its own blob without affecting the winner.
28//!
29//! See `new_long_term_revision` for the key construction.
30//!
31//! ## Compare-and-Swap
32//!
33//! All mutating operations follow a common pattern of reading the current
34//! revision, performing the upload, atomically swapping the revision (commit
35//! point), and cleaning up the now-unreferenced LT blob in the background:
36//!
37//! ### Large-Object Write (> 1 MiB)
38//!
39//! 1. **Read HV** to capture the current revision (existing tombstone target,
40//!    or absent).
41//! 2. **Write payload to LT** at a unique revision key.
42//! 3. **Compare-and-swap in HV**: write a tombstone pointing to the new
43//!    revision, only if the current revision still matches step 1.
44//!    - **OK** — schedule background deletion of the old LT blob, if any.
45//!    - **Conflict** — another writer won the race; schedule background deletion
46//!      of our new LT blob.
47//!    - **Error** — reload the tombstone and delete the unreferenced blob or
48//!      blobs.
49//!
50//! ### Small-Object Write (≤ 1 MiB)
51//!
52//! 1. **Write inline to HV**, skipping the write if a tombstone is present.
53//!    - **OK** — done; the object is stored entirely in HV.
54//!    - **Tombstone present** — a large object already occupies this key;
55//!      continue:
56//! 2. **Compare-and-swap in HV**: replace the tombstone with inline data, only
57//!    if the tombstone's revision still matches.
58//!    - **OK** — schedule background deletion of the old LT blob.
59//!    - **Conflict** — another writer won the race; they will clean up the
60//!      LT blob and we have no new LT blob to clean up.
61//!    - **Error** — reload the tombstone and delete the unreferenced blob if
62//!      the write went through.
63//!
64//! ### Delete
65//!
66//! 1. **Delete from HV** if the entry is not a tombstone.
67//!    - **OK** — done; there is no LT data to clean up.
68//!    - **Tombstone present** — a large object is stored here; continue:
69//! 2. **Compare-and-swap in HV**: remove the tombstone, only if its revision
70//!    still matches.
71//!    - **OK** — schedule background deletion of the LT blob.
72//!    - **Conflict** — another writer won the race; they will clean up.
73//!    - **Error** — reload the tombstone and delete the unreferenced blob if
74//!      the write went through.
75//!
76//! Tombstone removal is the commit point for deletes. If the subsequent LT
77//! cleanup fails, an orphan blob remains but the object is already unreachable
78//! through the normal read path.
79//!
80//! ## Last-Writer-Wins
81//!
82//! Concurrent mutations on the same key are inherently a race. Even a write
83//! that returns `Ok` may be immediately overwritten by another caller — there
84//! is no ordering guarantee and objectstore cannot provide a read-your-writes
85//! promise.
86//!
87//! CAS conflicts are therefore **not errors**: the losing writer's data is
88//! cleaned up and `Ok` is returned, because the result is indistinguishable
89//! from having succeeded a moment earlier and then been overwritten.
90//!
91//! ### Idempotency
92//!
93//! `compare_and_write` is idempotent: if the row is already in the target state, it
94//! returns `true` without re-applying the mutation. This is critical for retry
95//! safety. If the server commits a write but the response is lost, a retry sees the
96//! already-mutated state and still returns `true` — so callers do not mistakenly
97//! treat a successful commit as a lost race and clean up data that was actually
98//! persisted.
99
100use std::sync::Arc;
101use std::sync::atomic::{AtomicU64, Ordering};
102use std::time::{Duration, SystemTime};
103
104use base64::Engine as _;
105use bytes::Bytes;
106use futures_util::{Stream, StreamExt};
107use objectstore_types::metadata::Metadata;
108use objectstore_types::range::ByteRange;
109use sentry::{Hub, SentryFutureExt};
110use serde::{Deserialize, Serialize};
111
112use crate::backend::changelog::{Change, ChangeGuard, ChangeLog, ChangeManager, ChangePhase};
113use crate::backend::common::{
114    Backend, DeleteResponse, GetResponse, HighVolumeBackend, MetadataResponse,
115    MultipartUploadBackend, PutResponse, TieredGet, TieredMetadata, TieredWrite, Tombstone,
116};
117use crate::backend::{HighVolumeStorageConfig, MultipartUploadStorageConfig};
118use crate::error::{Error, Result};
119use crate::id::ObjectId;
120use crate::multipart::{
121    AbortMultipartResponse, CompleteMultipartResponse, CompletedPart, InitiateMultipartResponse,
122    ListPartsResponse, PartNumber, UploadId, UploadPartResponse,
123};
124use crate::stream::{ClientStream, SizedPeek};
125
126/// The threshold up until which we will go to the "high volume" backend.
127const BACKEND_SIZE_THRESHOLD: usize = 1024 * 1024; // 1 MiB
128
129/// Amount of time for which a `Change` generated by a `complete_multipart` operation is kept in the `Assembling`
130/// state before becoming eligible for cleanup by the `ChangeLog` recovery process.
131/// This allows the client to retry the `complete_multipart` operation upon any failures for at least this long,
132/// avoiding scenarios where the `ChangeLog` recovery would race to delete the assembled LT blob.
133const MULTIPART_COMPLETE_CLEANUP_DELAY: Duration = Duration::from_hours(24);
134
135/// Creates a new [`ObjectId`] with the same context but a unique revision key.
136///
137/// The new key has the format `{original_key}/{uuid_v7}`, producing a distinct
138/// storage path for each large-object write. [`ObjectId::from_storage_path`] parses
139/// the result back correctly because the key portion may contain `/`.
140fn new_long_term_revision(id: &ObjectId) -> ObjectId {
141    ObjectId {
142        context: id.context.clone(),
143        key: format!("{}/{}", id.key, uuid::Uuid::now_v7()),
144    }
145}
146
147/// Configuration for [`TieredStorage`].
148///
149/// Composes two backends into a tiered routing setup: `high_volume` for small
150/// objects and `long_term` for large objects. Nesting [`super::StorageConfig::Tiered`]
151/// inside another tiered config is not supported.
152///
153/// # Example
154///
155/// ```yaml
156/// storage:
157///   type: tiered
158///   high_volume:
159///     type: bigtable
160///     project_id: my-project
161///     instance_name: objectstore
162///     table_name: objectstore
163///   long_term:
164///     type: gcs
165///     bucket: my-objectstore-bucket
166/// ```
167#[derive(Debug, Clone, Deserialize, Serialize)]
168pub struct TieredStorageConfig {
169    /// Backend for high-volume, small objects.
170    ///
171    /// Must be a backend that implements [`HighVolumeBackend`] (currently
172    /// only BigTable).
173    pub high_volume: HighVolumeStorageConfig,
174    /// Backend for large, long-term objects.
175    ///
176    /// Must be a backend that implements [`MultipartUploadBackend`].
177    pub long_term: MultipartUploadStorageConfig,
178}
179
180/// Two-tier storage backend that routes objects by size.
181///
182/// `TieredStorage` implements [`Backend`] and is intended to be used inside a
183/// [`StorageService`](crate::StorageService), which wraps it with task spawning and panic
184/// isolation.
185///
186/// # Size-Based Routing
187///
188/// Objects are routed at write time based on their size relative to a **1 MiB threshold**:
189///
190/// - Objects **≤ 1 MiB** go to the `high_volume` backend — optimized for low-latency reads
191///   and writes of small objects (e.g. BigTable).
192/// - Objects **> 1 MiB** go to the `long_term` backend — optimized for cost-efficient
193///   storage of large objects (e.g. GCS).
194///
195/// # Redirect Tombstones
196///
197/// Because the [`ObjectId`] is backend-independent, reads must be able to find an object
198/// without knowing which backend stores it. A naive approach would check the long-term
199/// backend on every read miss in the high-volume backend — but that is slow and expensive.
200///
201/// Instead, when an object is stored in the long-term backend, a **redirect tombstone** is
202/// written in the high-volume backend. It acts as a signpost: "the real data lives in the
203/// other backend at this target." On reads, a single high-volume lookup either returns the
204/// object directly or follows the tombstone to long-term storage, without probing both
205/// backends.
206///
207/// How tombstones are physically stored is determined by the [`HighVolumeBackend`]
208/// implementation — refer to the backend's own documentation for storage format details.
209///
210/// # Consistency
211///
212/// Consistency across the two backends is maintained through compare-and-swap
213/// operations on the high-volume backend (see
214/// [`HighVolumeBackend::compare_and_write`]), not distributed locks. Each
215/// mutating operation reads the current high-volume revision, performs its
216/// work, and then atomically swaps the high-volume entry only if the revision
217/// is still current — rolling back on conflict. Cleanup of unreferenced LT
218/// blobs runs in background tasks so the caller returns as soon as the commit
219/// point is reached. Call [`Backend::join`] during shutdown to wait for
220/// outstanding cleanup.
221///
222/// See the [module-level documentation](self) for per-operation diagrams.
223///
224/// # Usage
225///
226/// `TieredStorage` handles only the routing and consistency logic. Wrap it in a
227/// [`StorageService`](crate::service::StorageService) to add task spawning, panic isolation,
228/// and concurrency limiting.
229#[derive(Debug)]
230pub struct TieredStorage {
231    inner: Arc<ChangeManager>,
232}
233
234impl TieredStorage {
235    /// Creates a new `TieredStorage` with the given backends and change log.
236    pub fn new(
237        high_volume: Box<dyn HighVolumeBackend>,
238        long_term: Box<dyn MultipartUploadBackend>,
239        changelog: Box<dyn ChangeLog>,
240    ) -> Self {
241        let inner = ChangeManager::new(high_volume, long_term, changelog);
242        let hub = Hub::new_from_top(Hub::current());
243        // Note on cancellation: Our `join` method will wait for all tasks tracked by the spawned
244        // recovery job, so we defer shutdown until recovery is complete or times out.
245        tokio::spawn(inner.clone().recover().bind_hub(hub));
246        Self { inner }
247    }
248
249    /// Records the change to the log and returns a guard that cleans up on drop.
250    async fn record_change(&self, change: Change) -> Result<ChangeGuard> {
251        self.inner.clone().record(change).await
252    }
253
254    /// Records the change to the log in the `Assembling` phase, and returns a guard that does
255    /// nothing on drop unless advanced.
256    async fn record_assembling(&self, change: Change) -> Result<ChangeGuard> {
257        self.inner.clone().record_assembling(change).await
258    }
259
260    /// Returns the name of the backend corresponding to the given routing choice.
261    fn backend_type(&self, choice: &BackendChoice) -> &'static str {
262        match choice {
263            BackendChoice::HighVolume => self.inner.high_volume.name(),
264            BackendChoice::LongTerm => self.inner.long_term.name(),
265        }
266    }
267
268    /// Puts an object into the high-volume backend.
269    ///
270    /// If a tombstone already exists, attempts to swap it for the new object and delete the old
271    /// long-term object.
272    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
273    async fn put_high_volume(
274        &self,
275        id: &ObjectId,
276        metadata: &Metadata,
277        payload: Bytes,
278    ) -> Result<()> {
279        let tombstone_opt = self
280            .inner
281            .high_volume
282            .put_non_tombstone(id, metadata, payload.clone())
283            .await?;
284
285        let Some(Tombstone { target, .. }) = tombstone_opt else {
286            // No tombstone exists - write succeeded
287            return Ok(());
288        };
289
290        // Tombstone exists — Swap it for inline data
291        let mut guard = self
292            .record_change(Change {
293                id: id.clone(),
294                new: None,
295                old: Some(target.clone()),
296                cleanup_after: None,
297            })
298            .await?;
299
300        let write = TieredWrite::Object(metadata.clone(), payload);
301        guard.advance(ChangePhase::Written);
302
303        let written = self
304            .inner
305            .high_volume
306            .compare_and_write(id, Some(&target), write)
307            .await?;
308
309        // Update guard and let it schedule cleanup in the background.
310        guard.advance(ChangePhase::compare_and_write(written));
311
312        Ok(())
313    }
314
315    /// Puts an object into the long-term backend with a redirect tombstone in front.
316    ///
317    /// Deletes the previous long-term object if overwriting an existing tombstone. If the tombstone
318    /// write fails, the new long-term object is cleaned up.
319    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
320    async fn put_long_term(
321        &self,
322        id: &ObjectId,
323        metadata: &Metadata,
324        stream: ClientStream,
325    ) -> Result<()> {
326        // 1. Read current HV revision to establish the write precondition
327        let current = match self.inner.high_volume.get_tiered_metadata(id).await? {
328            TieredMetadata::Tombstone(t) => Some(t.target),
329            _ => None,
330        };
331
332        // 2. Write payload to long-term at a unique revision key.
333        let new = new_long_term_revision(id);
334        let mut guard = self
335            .record_change(Change {
336                id: id.clone(),
337                new: Some(new.clone()),
338                old: current.clone(),
339                cleanup_after: None,
340            })
341            .await?;
342
343        self.inner
344            .long_term
345            .put_object(&new, metadata, stream)
346            .await?;
347        guard.advance(ChangePhase::Written);
348
349        // 3. CAS commit: write tombstone only if HV state matches what we saw.
350        let tombstone = Tombstone {
351            target: new.clone(),
352            expiration_policy: metadata.expiration_policy,
353        };
354        let written = self
355            .inner
356            .high_volume
357            .compare_and_write(id, current.as_ref(), TieredWrite::Tombstone(tombstone))
358            .await?;
359
360        // Update guard and let it schedule cleanup in the background.
361        guard.advance(ChangePhase::compare_and_write(written));
362
363        Ok(())
364    }
365}
366
367#[async_trait::async_trait]
368impl Backend for TieredStorage {
369    fn name(&self) -> &'static str {
370        "tiered"
371    }
372
373    fn as_multipart_upload_backend(&self) -> Result<&dyn MultipartUploadBackend> {
374        Ok(self)
375    }
376
377    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
378    async fn put_object(
379        &self,
380        id: &ObjectId,
381        metadata: &Metadata,
382        stream: ClientStream,
383    ) -> Result<PutResponse> {
384        let timer = objectstore_metrics::timer!("put.latency", usecase = id.usecase().to_owned());
385        if metadata.origin.is_none() {
386            objectstore_metrics::count!("put.origin_missing", usecase = id.usecase().to_owned());
387        }
388
389        let peeked = SizedPeek::new(stream, BACKEND_SIZE_THRESHOLD).await?;
390        objectstore_metrics::record!(
391            "put.first_chunk.latency" = timer.elapsed(),
392            usecase = id.usecase().to_owned(),
393            complete = if peeked.is_exhausted() { "yes" } else { "no" },
394        );
395
396        let (backend_choice, stored_size) = if peeked.is_exhausted() {
397            let payload = peeked.into_bytes().await?;
398            let payload_len = payload.len() as u64;
399            self.put_high_volume(id, metadata, payload).await?;
400            (BackendChoice::HighVolume, payload_len)
401        } else {
402            let (stored_size, stream) = counting_stream(peeked.into_stream());
403            self.put_long_term(id, metadata, stream.boxed()).await?;
404            (BackendChoice::LongTerm, stored_size.load(Ordering::Acquire))
405        };
406
407        let backend_ty = self.backend_type(&backend_choice);
408        timer
409            .tag("backend_choice", backend_choice.as_str())
410            .tag("backend_type", backend_ty)
411            .record();
412        objectstore_metrics::record!(
413            "put.size" = stored_size,
414            usecase = id.usecase().to_owned(),
415            backend_choice = backend_choice.as_str(),
416            backend_type = backend_ty,
417            upload_type = "direct",
418        );
419
420        Ok(())
421    }
422
423    #[tracing::instrument(level = "debug", skip(self))]
424    async fn get_object(&self, id: &ObjectId, range: Option<ByteRange>) -> Result<GetResponse> {
425        let timer = objectstore_metrics::timer!(
426            "get.latency.pre-response",
427            usecase = id.usecase().to_owned(),
428        );
429
430        let hv_result = self.inner.high_volume.get_tiered_object(id, range).await?;
431        let (result, backend_choice) = match hv_result {
432            TieredGet::NotFound => (None, BackendChoice::HighVolume),
433            TieredGet::Object(metadata, content_range, stream) => (
434                Some((metadata, content_range, stream)),
435                BackendChoice::HighVolume,
436            ),
437            TieredGet::Tombstone(tombstone) => (
438                self.inner
439                    .long_term
440                    .get_object(&tombstone.target, range)
441                    .await?,
442                BackendChoice::LongTerm,
443            ),
444        };
445
446        let backend_type = self.backend_type(&backend_choice);
447        timer
448            .tag("backend_choice", backend_choice.as_str())
449            .tag("backend_type", backend_type)
450            .record();
451
452        if let Some((ref metadata, ref content_range, _)) = result {
453            let size = content_range.map(|cr| cr.len() as usize).or(metadata.size);
454            if let Some(size) = size {
455                objectstore_metrics::record!(
456                    "get.size" = size,
457                    usecase = id.usecase().to_owned(),
458                    backend_choice = backend_choice.as_str(),
459                    backend_type = backend_type,
460                );
461            }
462        }
463
464        Ok(result)
465    }
466
467    #[tracing::instrument(level = "debug", skip(self))]
468    async fn get_metadata(&self, id: &ObjectId) -> Result<MetadataResponse> {
469        let timer = objectstore_metrics::timer!("head.latency", usecase = id.usecase().to_owned());
470
471        let hv_result = self.inner.high_volume.get_tiered_metadata(id).await?;
472        let (result, backend_choice) = match hv_result {
473            TieredMetadata::NotFound => (None, BackendChoice::HighVolume),
474            TieredMetadata::Object(metadata) => (Some(metadata), BackendChoice::HighVolume),
475            TieredMetadata::Tombstone(tombstone) => (
476                self.inner.long_term.get_metadata(&tombstone.target).await?,
477                BackendChoice::LongTerm,
478            ),
479        };
480
481        timer
482            .tag("backend_choice", backend_choice.as_str())
483            .tag("backend_type", self.backend_type(&backend_choice))
484            .record();
485
486        Ok(result)
487    }
488
489    #[tracing::instrument(level = "debug", skip(self))]
490    async fn delete_object(&self, id: &ObjectId) -> Result<DeleteResponse> {
491        let timer =
492            objectstore_metrics::timer!("delete.latency", usecase = id.usecase().to_owned());
493
494        let mut backend_choice = BackendChoice::HighVolume;
495
496        if let Some(tombstone) = self.inner.high_volume.delete_non_tombstone(id).await? {
497            backend_choice = BackendChoice::LongTerm;
498
499            let mut guard = self
500                .record_change(Change {
501                    id: id.clone(),
502                    new: None,
503                    old: Some(tombstone.target.clone()),
504                    cleanup_after: None,
505                })
506                .await?;
507            guard.advance(ChangePhase::Written);
508
509            // Remove the tombstone; the LT blob becomes unreachable at this point.
510            let deleted = self
511                .inner
512                .high_volume
513                .compare_and_write(id, Some(&tombstone.target), TieredWrite::Delete)
514                .await?;
515
516            // Update guard and let it schedule cleanup in the background.
517            guard.advance(ChangePhase::compare_and_write(deleted));
518        }
519
520        timer
521            .tag("backend_choice", backend_choice.as_str())
522            .tag("backend_type", self.backend_type(&backend_choice))
523            .record();
524
525        Ok(())
526    }
527
528    async fn join(&self) {
529        self.inner.tracker.close();
530        tokio::join!(
531            self.inner.high_volume.join(),
532            self.inner.long_term.join(),
533            self.inner.tracker.wait()
534        );
535    }
536}
537
538#[derive(Debug)]
539enum BackendChoice {
540    HighVolume,
541    LongTerm,
542}
543
544impl BackendChoice {
545    fn as_str(&self) -> &'static str {
546        match self {
547            BackendChoice::HighVolume => "high-volume",
548            BackendChoice::LongTerm => "long-term",
549        }
550    }
551}
552
553impl std::fmt::Display for BackendChoice {
554    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
555        f.write_str(self.as_str())
556    }
557}
558
559/// Wraps a stream to count the total bytes yielded by successful chunks.
560///
561/// Returns the shared counter and the wrapped stream. The counter is incremented
562/// as the stream is consumed, so read it only after the stream is exhausted.
563fn counting_stream<S, E>(stream: S) -> (Arc<AtomicU64>, impl Stream<Item = Result<Bytes, E>>)
564where
565    S: Stream<Item = Result<Bytes, E>>,
566{
567    let counter = Arc::new(AtomicU64::new(0));
568
569    (
570        counter.clone(),
571        stream.inspect(move |res| {
572            if let Ok(chunk) = res {
573                counter.fetch_add(chunk.len() as u64, Ordering::Relaxed);
574            }
575        }),
576    )
577}
578
579/// The multipart upload state for TieredStorage.
580#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
581struct TieredUploadId {
582    revision: String,
583    upload_id: UploadId,
584}
585
586impl TryInto<UploadId> for TieredUploadId {
587    type Error = Error;
588
589    fn try_into(self) -> Result<UploadId, Self::Error> {
590        let json =
591            serde_json::to_vec(&self).map_err(|e| Error::serde("encoding multipart token", e))?;
592        Ok(UploadId::new(
593            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json),
594        )?)
595    }
596}
597
598impl TryFrom<&UploadId> for TieredUploadId {
599    type Error = Error;
600
601    fn try_from(value: &UploadId) -> Result<Self, Self::Error> {
602        let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
603            .decode(value.as_bytes())
604            .map_err(|e| Error::generic(format!("invalid multipart upload ID: {e}")))?;
605        serde_json::from_slice(&json).map_err(|e| Error::serde("decoding multipart token", e))
606    }
607}
608
609#[async_trait::async_trait]
610impl MultipartUploadBackend for TieredStorage {
611    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
612    async fn initiate_multipart(
613        &self,
614        id: &ObjectId,
615        metadata: &Metadata,
616    ) -> Result<InitiateMultipartResponse> {
617        let timer = objectstore_metrics::timer!(
618            "multipart.initiate.latency",
619            usecase = id.usecase().to_owned(),
620        );
621        let physical = new_long_term_revision(id);
622
623        let upload_id = self
624            .inner
625            .long_term
626            .initiate_multipart(&physical, metadata)
627            .await?;
628
629        let id = TieredUploadId {
630            revision: physical.key,
631            upload_id,
632        };
633        let id = id.try_into()?;
634
635        timer.record();
636        Ok(id)
637    }
638
639    #[tracing::instrument(level = "debug", fields(?id, part_number, content_length), skip_all)]
640    async fn upload_part(
641        &self,
642        id: &ObjectId,
643        upload_id: &UploadId,
644        part_number: PartNumber,
645        content_length: u64,
646        content_md5: Option<&str>,
647        body: ClientStream,
648    ) -> Result<UploadPartResponse> {
649        let timer = objectstore_metrics::timer!(
650            "multipart.upload_part.latency",
651            usecase = id.usecase().to_owned(),
652        );
653        let tiered: TieredUploadId = upload_id.try_into()?;
654
655        let physical = ObjectId {
656            context: id.context.clone(),
657            key: tiered.revision,
658        };
659
660        let etag = self
661            .inner
662            .long_term
663            .upload_part(
664                &physical,
665                &tiered.upload_id,
666                part_number,
667                content_length,
668                content_md5,
669                body,
670            )
671            .await?;
672
673        timer.record();
674        objectstore_metrics::record!(
675            "multipart.upload_part.size" = content_length,
676            usecase = id.usecase().to_owned(),
677        );
678
679        Ok(etag)
680    }
681
682    #[tracing::instrument(level = "debug", skip(self, upload_id))]
683    async fn list_parts(
684        &self,
685        id: &ObjectId,
686        upload_id: &UploadId,
687        max_parts: Option<u32>,
688        part_number_marker: Option<PartNumber>,
689    ) -> Result<ListPartsResponse> {
690        let timer = objectstore_metrics::timer!(
691            "multipart.list_parts.latency",
692            usecase = id.usecase().to_owned(),
693        );
694        let tiered: TieredUploadId = upload_id.try_into()?;
695
696        let physical = ObjectId {
697            context: id.context.clone(),
698            key: tiered.revision,
699        };
700
701        let response = self
702            .inner
703            .long_term
704            .list_parts(&physical, &tiered.upload_id, max_parts, part_number_marker)
705            .await?;
706
707        timer.record();
708        Ok(response)
709    }
710
711    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
712    async fn abort_multipart(
713        &self,
714        id: &ObjectId,
715        upload_id: &UploadId,
716    ) -> Result<AbortMultipartResponse> {
717        let timer = objectstore_metrics::timer!(
718            "multipart.abort.latency",
719            usecase = id.usecase().to_owned(),
720        );
721        let tiered: TieredUploadId = upload_id.try_into()?;
722
723        let physical = ObjectId {
724            context: id.context.clone(),
725            key: tiered.revision,
726        };
727
728        let () = self
729            .inner
730            .long_term
731            .abort_multipart(&physical, &tiered.upload_id)
732            .await?;
733
734        timer.record();
735        Ok(())
736    }
737
738    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
739    async fn complete_multipart(
740        &self,
741        id: &ObjectId,
742        upload_id: &UploadId,
743        parts: Vec<CompletedPart>,
744    ) -> Result<CompleteMultipartResponse> {
745        let timer = objectstore_metrics::timer!(
746            "multipart.complete.latency",
747            usecase = id.usecase().to_owned(),
748        );
749        let part_count = parts.len();
750        let tiered: TieredUploadId = upload_id.try_into()?;
751
752        let physical = ObjectId {
753            context: id.context.clone(),
754            key: tiered.revision,
755        };
756
757        // 1. Read current HV revision to establish the write precondition.
758        let current = match self.inner.high_volume.get_tiered_metadata(id).await? {
759            // Optimization: a previous attempt already finalized this revision and tombstone -- report success.
760            TieredMetadata::Tombstone(t) if t.target == physical => {
761                timer.record();
762                return Ok(None);
763            }
764            TieredMetadata::Tombstone(t) => Some(t.target),
765            _ => None,
766        };
767
768        // Register a guard with cleanup deferred to now + `MULTIPART_COMPLETE_CLEANUP_DELAY`,
769        // so that the user has the chance to retry finalizing the upload in this timeframe.
770        let mut guard = self
771            .record_assembling(Change {
772                id: id.clone(),
773                new: Some(physical.clone()),
774                old: current.clone(),
775                cleanup_after: Some(SystemTime::now() + MULTIPART_COMPLETE_CLEANUP_DELAY),
776            })
777            .await?;
778
779        // 2. Complete the upload, creating the object at the given revision key.
780        let maybe_complete_multipart_err = match self
781            .inner
782            .long_term
783            .complete_multipart(&physical, &tiered.upload_id, parts)
784            .await
785        {
786            // The request went through but we got an error in the response body.
787            // Transparently proxy the error to the user.
788            Ok(error) => {
789                if error.is_some() {
790                    return Ok(error);
791                }
792                None
793            }
794            // We got status 4xx/5xx, or a network error.
795            // Either way, `complete_multipart` might have been completed successfully,
796            // either now or in a previous attempt (in that case, that's a 404 and we indeed end up
797            // here).
798            // We cannot know if that's the case yet, so we continue to the next steps.
799            Err(err) => Some(err),
800        };
801
802        // 3. Retrieve the metadata of the object, which was determined at initiation time, to
803        //    get the expiration policy.
804        //
805        //    This also serves as an existence check to understand if the LT revision was actually
806        //    created successfully in this or a previous attempt, in which case we just need to
807        //    finalize the tombstone.
808        let metadata = self.inner.long_term.get_metadata(&physical).await;
809
810        let metadata = match (metadata, maybe_complete_multipart_err) {
811            // The LT revision already exists, so we can continue to finalize the tombstone.
812            (Ok(Some(metadata)), _) => metadata,
813            // The LT revision doesn't exist, cannot proceed.
814            (Ok(None), Some(err)) => return Err(err),
815            // The `complete_multipart` succeeded, creating the object, but the `get_metadata`
816            // immediately after failed to find the object. This should never happen.
817            (Ok(None), None) => {
818                objectstore_log::error!(
819                    id = ?id,
820                    upload_id = ?upload_id,
821                    physical = ?physical,
822                    "complete_multipart call succeeded on long_term backend, but subsequent get_metadata found no object"
823                );
824                return Err(Error::generic(
825                    "completed multipart object not found in long-term storage",
826                ));
827            }
828            // Failed to `get_metadata`, cannot proceed.
829            (Err(get_metadata_err), maybe_complete_multipart_err) => {
830                // Prefer the `complete_multipart_err`, as it's likely more informative.
831                // TODO(FS-358): convert this properly. Right now `ApiErrorResponse` will turn this into a 500,
832                // but we would actually want to transparently surface the original status (and message?) instead.
833                return Err(maybe_complete_multipart_err.unwrap_or(get_metadata_err));
834            }
835        };
836
837        // 4. CAS commit: write tombstone only if HV state matches what we saw.
838        let tombstone = Tombstone {
839            target: physical.clone(),
840            expiration_policy: metadata.expiration_policy,
841        };
842        let written = self
843            .inner
844            .high_volume
845            .compare_and_write(id, current.as_ref(), TieredWrite::Tombstone(tombstone))
846            .await?;
847
848        // Update guard and let it schedule cleanup in the background.
849        guard.advance(ChangePhase::compare_and_write(written));
850
851        timer.record();
852        objectstore_metrics::record!(
853            "multipart.complete.part_count" = part_count as u64,
854            usecase = id.usecase().to_owned(),
855        );
856        if let Some(size) = metadata.size {
857            objectstore_metrics::record!(
858                "put.size" = size as u64,
859                usecase = id.usecase().to_owned(),
860                backend_choice = BackendChoice::LongTerm.as_str(),
861                backend_type = self.backend_type(&BackendChoice::LongTerm),
862                upload_type = "multipart",
863            );
864        }
865
866        Ok(None)
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use std::num::NonZeroU32;
873
874    use futures::lock::Mutex;
875    use objectstore_types::metadata::{ExpirationPolicy, Metadata};
876    use objectstore_types::scope::{Scope, Scopes};
877
878    use super::*;
879    use crate::backend::changelog::{InMemoryChangeLog, NoopChangeLog};
880    use crate::backend::in_memory::InMemoryBackend;
881    use crate::backend::testing::{Hooks, TestBackend};
882    use crate::error::Error;
883    use crate::id::ObjectContext;
884
885    use crate::stream::{self, ClientStream};
886
887    fn make_context() -> ObjectContext {
888        ObjectContext {
889            usecase: "testing".into(),
890            scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
891        }
892    }
893
894    fn make_id(key: &str) -> ObjectId {
895        ObjectId::new(make_context(), key.into())
896    }
897
898    fn make_tiered_storage() -> (
899        TieredStorage,
900        InMemoryBackend,
901        InMemoryBackend,
902        InMemoryChangeLog,
903    ) {
904        let hv = InMemoryBackend::new("in-memory-hv");
905        let lt = InMemoryBackend::new("in-memory-lt");
906        let changelog = InMemoryChangeLog::default();
907        let storage = TieredStorage::new(
908            Box::new(hv.clone()),
909            Box::new(lt.clone()),
910            Box::new(changelog.clone()),
911        );
912        (storage, hv, lt, changelog)
913    }
914
915    // --- new_long_term_revision tests ---
916
917    #[test]
918    fn revision_id_preserves_context() {
919        let id = make_id("my-key");
920        let revised = new_long_term_revision(&id);
921        assert_eq!(revised.context, id.context);
922        assert!(
923            revised.key.starts_with("my-key/"),
924            "revised key should have /<uuid> suffix, got: {}",
925            revised.key
926        );
927    }
928
929    #[test]
930    fn revision_id_roundtrips_storage_path() {
931        let id = make_id("original");
932        let revised = new_long_term_revision(&id);
933        let path = revised.as_storage_path().to_string();
934        let parsed = ObjectId::from_storage_path(&path)
935            .unwrap_or_else(|| panic!("failed to parse '{path}'"));
936        assert_eq!(parsed, revised);
937    }
938
939    #[test]
940    fn revision_id_is_unique() {
941        let id = make_id("base-key");
942        let a = new_long_term_revision(&id);
943        let b = new_long_term_revision(&id);
944        assert_ne!(a.key, b.key, "two calls should produce different keys");
945    }
946
947    // --- Basic behavior ---
948
949    #[tokio::test]
950    async fn get_nonexistent_returns_none() {
951        let (storage, _hv, _lt, _) = make_tiered_storage();
952        let id = make_id("does-not-exist");
953
954        assert!(storage.get_object(&id, None).await.unwrap().is_none());
955        assert!(storage.get_metadata(&id).await.unwrap().is_none());
956    }
957
958    #[tokio::test]
959    async fn delete_nonexistent_succeeds() {
960        let (storage, _hv, _lt, _) = make_tiered_storage();
961        let id = make_id("does-not-exist");
962
963        storage.delete_object(&id).await.unwrap();
964    }
965
966    // --- Put routing ---
967
968    #[tokio::test]
969    async fn put_small_object_stores_inline() {
970        let (storage, hv, lt, _) = make_tiered_storage();
971        let id = make_id("small");
972        let payload = b"small payload".to_vec();
973
974        storage
975            .put_object(&id, &Metadata::default(), stream::single(payload.clone()))
976            .await
977            .unwrap();
978
979        assert!(hv.contains(&id), "expected in high-volume");
980        assert!(!lt.contains(&id), "leaked to long-term");
981
982        let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
983        let body = stream::read_to_vec(s).await.unwrap();
984        assert_eq!(body, payload);
985
986        assert!(
987            storage.get_metadata(&id).await.unwrap().is_some(),
988            "get_metadata should return metadata for inline objects"
989        );
990    }
991
992    #[tokio::test]
993    async fn put_large_object_creates_tombstone() {
994        let (storage, hv, lt, _) = make_tiered_storage();
995        let id = make_id("large");
996        let payload = vec![0xCDu8; 2 * 1024 * 1024]; // 2 MiB, over threshold
997        let metadata_in = Metadata {
998            content_type: "image/png".into(),
999            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1000            origin: Some("10.0.0.1".into()),
1001            ..Metadata::default()
1002        };
1003
1004        storage
1005            .put_object(&id, &metadata_in, stream::single(payload.clone()))
1006            .await
1007            .unwrap();
1008
1009        // Tombstone in HV: correct expiration_policy, target is a revision key.
1010        let tombstone = hv.get(&id).expect_tombstone();
1011        assert_eq!(tombstone.expiration_policy, metadata_in.expiration_policy);
1012        let lt_id = tombstone.target;
1013        assert!(
1014            lt_id.key().starts_with(id.key()),
1015            "tombstone target key should be a revision of the HV key, got: {}",
1016            lt_id.key()
1017        );
1018
1019        // LT object at revision key with correct metadata.
1020        let (lt_meta, _) = lt.get(&lt_id).expect_object();
1021        assert_eq!(lt_meta.content_type, "image/png");
1022        assert_eq!(lt_meta.expiration_policy, metadata_in.expiration_policy);
1023
1024        // get_object follows the tombstone and returns the correct payload.
1025        let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
1026        let body = stream::read_to_vec(s).await.unwrap();
1027        assert_eq!(body, payload);
1028
1029        // get_metadata follows the tombstone and returns the correct content_type.
1030        let metadata = storage.get_metadata(&id).await.unwrap().unwrap();
1031        assert_eq!(metadata.content_type, "image/png");
1032    }
1033
1034    // --- Put overwrites ---
1035
1036    #[tokio::test]
1037    async fn reinsert_small_over_large_swaps_to_inline() {
1038        let (storage, hv, lt, _) = make_tiered_storage();
1039        let id = make_id("reinsert-key");
1040
1041        // First: insert a large object → creates tombstone in hv, payload in lt at lt_id
1042        let large_payload = vec![0xABu8; 2 * 1024 * 1024];
1043        storage
1044            .put_object(&id, &Metadata::default(), stream::single(large_payload))
1045            .await
1046            .unwrap();
1047
1048        let lt_id = hv.get(&id).expect_tombstone().target;
1049
1050        // Re-insert a SMALL payload with the same key.
1051        // The CAS-swap puts the small object inline in HV and schedules background cleanup.
1052        let small_payload = vec![0xCDu8; 100]; // well under 1 MiB threshold
1053        storage
1054            .put_object(&id, &Metadata::default(), stream::single(small_payload))
1055            .await
1056            .unwrap();
1057
1058        // The small object is now inline in high-volume.
1059        hv.get(&id).expect_object();
1060
1061        // Drain background cleanup tasks before asserting LT state.
1062        storage.join().await;
1063
1064        // The old long-term blob was cleaned up.
1065        lt.get(&lt_id).expect_not_found();
1066    }
1067
1068    #[tokio::test]
1069    async fn overwrite_large_with_large_replaces_revision() {
1070        let (storage, hv, lt, _) = make_tiered_storage();
1071        let id = make_id("overwrite-large");
1072
1073        let payload1 = vec![0xAAu8; 2 * 1024 * 1024];
1074        storage
1075            .put_object(&id, &Metadata::default(), stream::single(payload1))
1076            .await
1077            .unwrap();
1078        let lt_id_1 = hv.get(&id).expect_tombstone().target;
1079
1080        let payload2 = vec![0xBBu8; 2 * 1024 * 1024];
1081        storage
1082            .put_object(&id, &Metadata::default(), stream::single(payload2.clone()))
1083            .await
1084            .unwrap();
1085        let lt_id_2 = hv.get(&id).expect_tombstone().target;
1086
1087        assert_ne!(
1088            lt_id_1, lt_id_2,
1089            "second write should create a new revision"
1090        );
1091
1092        // Drain background cleanup tasks before asserting LT state.
1093        storage.join().await;
1094
1095        lt.get(&lt_id_1).expect_not_found();
1096        lt.get(&lt_id_2).expect_object();
1097
1098        let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
1099        let body = stream::read_to_vec(s).await.unwrap();
1100        assert_eq!(body, payload2);
1101    }
1102
1103    // --- Delete ---
1104
1105    #[tokio::test]
1106    async fn delete_small_object() {
1107        let (storage, hv, _lt, _) = make_tiered_storage();
1108        let id = make_id("delete-small");
1109
1110        storage
1111            .put_object(&id, &Metadata::default(), stream::single("tiny"))
1112            .await
1113            .unwrap();
1114
1115        storage.delete_object(&id).await.unwrap();
1116
1117        hv.get(&id).expect_not_found();
1118        assert!(storage.get_object(&id, None).await.unwrap().is_none());
1119    }
1120
1121    #[tokio::test]
1122    async fn delete_large_object_cleans_up_both_backends() {
1123        let (storage, hv, lt, _) = make_tiered_storage();
1124        let id = make_id("delete-both");
1125        let payload = vec![0u8; 2 * 1024 * 1024]; // 2 MiB
1126
1127        storage
1128            .put_object(&id, &Metadata::default(), stream::single(payload))
1129            .await
1130            .unwrap();
1131
1132        // Capture lt_id before deleting (it lives at the revision key, not at id).
1133        let lt_id = hv.get(&id).expect_tombstone().target;
1134
1135        storage.delete_object(&id).await.unwrap();
1136
1137        // Drain background cleanup tasks before asserting LT state.
1138        storage.join().await;
1139
1140        assert!(!hv.contains(&id), "tombstone not cleaned up");
1141        assert!(!lt.contains(&lt_id), "long-term object not cleaned up");
1142    }
1143
1144    #[derive(Debug)]
1145    struct FailDelete;
1146
1147    #[async_trait::async_trait]
1148    impl Hooks for FailDelete {
1149        async fn delete_object(
1150            &self,
1151            _inner: &InMemoryBackend,
1152            _id: &ObjectId,
1153        ) -> Result<DeleteResponse> {
1154            Err(Error::Io(std::io::Error::new(
1155                std::io::ErrorKind::ConnectionRefused,
1156                "simulated long-term delete failure",
1157            )))
1158        }
1159    }
1160
1161    /// When the long-term GCS cleanup fails after the tombstone is deleted, the
1162    /// delete still succeeds (GCS cleanup is best-effort). An orphan blob may
1163    /// remain in LT storage, which is accepted.
1164    #[tokio::test]
1165    async fn delete_succeeds_when_gcs_cleanup_fails() {
1166        let hv = InMemoryBackend::new("hv");
1167        let lt = TestBackend::new(FailDelete);
1168        let log = NoopChangeLog;
1169        let storage = TieredStorage::new(Box::new(hv.clone()), Box::new(lt), Box::new(log));
1170
1171        let id = make_id("fail-delete");
1172        let payload = vec![0xABu8; 2 * 1024 * 1024]; // 2 MiB -> goes to long-term
1173        storage
1174            .put_object(&id, &Metadata::default(), stream::single(payload))
1175            .await
1176            .unwrap();
1177
1178        // Delete succeeds even though GCS cleanup fails (it is best-effort).
1179        let result = storage.delete_object(&id).await;
1180        assert!(
1181            result.is_ok(),
1182            "delete should succeed despite GCS cleanup failure"
1183        );
1184
1185        // The tombstone in HV is gone (CAS-deleted first, before GCS cleanup).
1186        hv.get(&id).expect_not_found();
1187
1188        // The orphaned GCS blob remains but the object is unreachable through the service.
1189        assert!(
1190            storage.get_object(&id, None).await.unwrap().is_none(),
1191            "object should be unreachable after tombstone is deleted"
1192        );
1193    }
1194
1195    // --- CAS conflicts ---
1196
1197    #[derive(Debug)]
1198    struct CasConflict;
1199
1200    #[async_trait::async_trait]
1201    impl Hooks for CasConflict {
1202        async fn compare_and_write(
1203            &self,
1204            _inner: &InMemoryBackend,
1205            _id: &ObjectId,
1206            _current: Option<&ObjectId>,
1207            _write: TieredWrite,
1208        ) -> Result<bool> {
1209            Ok(false) // always conflict
1210        }
1211    }
1212
1213    /// After a large-object write loses the CAS race, the new LT blob must be
1214    /// cleaned up. The put still returns `Ok(())` — from the caller's view, a
1215    /// concurrent write won.
1216    #[tokio::test]
1217    async fn put_large_cas_conflict_cleans_up_new_blob() {
1218        let hv = TestBackend::new(CasConflict);
1219        let lt = InMemoryBackend::new("lt");
1220        let log = NoopChangeLog;
1221        let storage = TieredStorage::new(Box::new(hv), Box::new(lt.clone()), Box::new(log));
1222
1223        let id = make_id("cas-conflict-large");
1224        let payload = vec![0xABu8; 2 * 1024 * 1024]; // 2 MiB -> long-term path
1225
1226        storage
1227            .put_object(&id, &Metadata::default(), stream::single(payload))
1228            .await
1229            .unwrap();
1230
1231        // Drain background cleanup tasks before asserting LT state.
1232        storage.join().await;
1233
1234        assert!(
1235            lt.is_empty(),
1236            "LT blob should be cleaned up after CAS conflict"
1237        );
1238    }
1239
1240    /// When swapping a tombstone for inline data, a CAS conflict means another
1241    /// writer won. The put still returns `Ok(())` — no LT blob was written, so
1242    /// there is nothing to clean up.
1243    #[tokio::test]
1244    async fn put_small_over_tombstone_cas_conflict_succeeds() {
1245        let inner = InMemoryBackend::new("hv");
1246        let id = make_id("cas-conflict-small");
1247
1248        // Pre-seed a tombstone directly in the inner backend so put_non_tombstone
1249        // returns it instead of writing inline.
1250        let tombstone = Tombstone {
1251            target: make_id("lt-object"),
1252            expiration_policy: ExpirationPolicy::Manual,
1253        };
1254        inner
1255            .compare_and_write(&id, None, TieredWrite::Tombstone(tombstone))
1256            .await
1257            .unwrap();
1258
1259        let lt = InMemoryBackend::new("lt");
1260        let hv = TestBackend::with_inner(inner, CasConflict);
1261        let log = NoopChangeLog;
1262        let storage = TieredStorage::new(Box::new(hv), Box::new(lt), Box::new(log));
1263
1264        // Writing a small object over a tombstone should succeed even when CAS
1265        // conflicts — the other writer's write is accepted.
1266        storage
1267            .put_object(&id, &Metadata::default(), stream::single("tiny"))
1268            .await
1269            .unwrap();
1270    }
1271
1272    // --- Failure / inconsistency ---
1273
1274    /// Simulates compare_and_write failure. If `true`, it fails after commit.
1275    #[derive(Debug)]
1276    struct FailCas(bool);
1277
1278    #[async_trait::async_trait]
1279    impl Hooks for FailCas {
1280        async fn compare_and_write(
1281            &self,
1282            inner: &InMemoryBackend,
1283            id: &ObjectId,
1284            current: Option<&ObjectId>,
1285            write: TieredWrite,
1286        ) -> Result<bool> {
1287            if self.0 {
1288                // simulate a network error _after_ commit went through
1289                inner.compare_and_write(id, current, write).await?;
1290            }
1291            Err(Error::Io(std::io::Error::new(
1292                std::io::ErrorKind::TimedOut,
1293                "simulated compare_and_write failure",
1294            )))
1295        }
1296    }
1297
1298    /// If the tombstone write to the high-volume backend fails after the long-term
1299    /// write succeeds, the long-term object must be cleaned up so we never leave
1300    /// an unreachable orphan in long-term storage.
1301    #[tokio::test]
1302    async fn no_orphan_when_tombstone_write_fails() {
1303        let lt = InMemoryBackend::new("lt");
1304        let hv = TestBackend::new(FailCas(false));
1305        let log = NoopChangeLog;
1306        let storage = TieredStorage::new(Box::new(hv), Box::new(lt.clone()), Box::new(log));
1307
1308        let id = make_id("orphan-test");
1309        let payload = vec![0xABu8; 2 * 1024 * 1024]; // 2 MiB -> long-term path
1310        let result = storage
1311            .put_object(&id, &Metadata::default(), stream::single(payload))
1312            .await;
1313
1314        assert!(result.is_err());
1315
1316        // Drain background cleanup tasks before asserting LT state.
1317        storage.join().await;
1318
1319        assert!(lt.is_empty(), "long-term object not cleaned up");
1320    }
1321
1322    /// If a tombstone exists in high-volume but the corresponding object is
1323    /// missing from long-term storage (e.g. due to a race condition or partial
1324    /// cleanup), reads should gracefully return None rather than error.
1325    #[tokio::test]
1326    async fn orphan_tombstone_returns_none() {
1327        let (storage, hv, lt, _) = make_tiered_storage();
1328        let id = make_id("orphan-tombstone");
1329        let payload = vec![0xCDu8; 2 * 1024 * 1024]; // 2 MiB
1330
1331        storage
1332            .put_object(&id, &Metadata::default(), stream::single(payload))
1333            .await
1334            .unwrap();
1335
1336        // The object is at the revision key in LT, not at id.
1337        let lt_id = hv.get(&id).expect_tombstone().target;
1338
1339        // Remove the long-term object, leaving an orphan tombstone in hv
1340        lt.remove(&lt_id);
1341
1342        assert!(
1343            storage.get_object(&id, None).await.unwrap().is_none(),
1344            "orphan tombstone should resolve to None on get_object"
1345        );
1346        assert!(
1347            storage.get_metadata(&id).await.unwrap().is_none(),
1348            "orphan tombstone should resolve to None on get_metadata"
1349        );
1350    }
1351
1352    // --- Redirect target ---
1353
1354    /// A tombstone carrying an explicit `target` is followed correctly on reads and deletes,
1355    /// including when the target ObjectId differs from the HV ObjectId.
1356    #[tokio::test]
1357    async fn tombstone_target_is_used_for_reads_and_deletes() {
1358        let hv = InMemoryBackend::new("hv");
1359        let lt = InMemoryBackend::new("lt");
1360        let log = NoopChangeLog;
1361        let storage = TieredStorage::new(Box::new(hv.clone()), Box::new(lt.clone()), Box::new(log));
1362
1363        let hv_id = make_id("hv-key");
1364        let lt_id = make_id("lt-key");
1365        let payload = vec![0xABu8; 100];
1366
1367        // Write the object under the LT id and a tombstone pointing to it from HV.
1368        lt.put_object(
1369            &lt_id,
1370            &Metadata::default(),
1371            stream::single(payload.clone()),
1372        )
1373        .await
1374        .unwrap();
1375        let tombstone = Tombstone {
1376            target: lt_id.clone(),
1377            expiration_policy: ExpirationPolicy::Manual,
1378        };
1379        hv.compare_and_write(&hv_id, None, TieredWrite::Tombstone(tombstone))
1380            .await
1381            .unwrap();
1382
1383        // get_object must follow the tombstone and find the object via the lt_id target.
1384        let (_, _, s) = storage.get_object(&hv_id, None).await.unwrap().unwrap();
1385        let body = stream::read_to_vec(s).await.unwrap();
1386        assert_eq!(body, payload);
1387
1388        // delete_object must clean up both backends using the target.
1389        storage.delete_object(&hv_id).await.unwrap();
1390        storage.join().await;
1391        assert!(!hv.contains(&hv_id), "tombstone should be removed");
1392        assert!(!lt.contains(&lt_id), "lt object should be removed");
1393    }
1394
1395    // --- Multi-chunk ---
1396
1397    #[tokio::test]
1398    async fn multi_chunk_large_object_chains_buffered_and_remaining() {
1399        let (storage, hv, lt, _) = make_tiered_storage();
1400        let id = make_id("multi-chunk");
1401
1402        // Deliver a 2 MiB payload across multiple chunks that individually
1403        // fit under the threshold but collectively exceed it.
1404        let chunk_size = 512 * 1024; // 512 KiB per chunk
1405        let chunk_count = 4; // 4 × 512 KiB = 2 MiB total
1406        let stream: ClientStream = futures_util::stream::iter(
1407            (0..chunk_count).map(move |i| Ok(Bytes::from(vec![i as u8; chunk_size]))),
1408        )
1409        .boxed();
1410
1411        storage
1412            .put_object(&id, &Metadata::default(), stream)
1413            .await
1414            .unwrap();
1415
1416        // Should have been routed to long-term (over 1 MiB) at the revision key.
1417        let lt_id = hv.get(&id).expect_tombstone().target;
1418        let (_, lt_bytes) = lt.get(&lt_id).expect_object();
1419        assert_eq!(lt_bytes.len(), chunk_size * chunk_count);
1420
1421        // Verify data integrity — each chunk's fill byte should appear in order.
1422        for i in 0..chunk_count {
1423            let offset = i * chunk_size;
1424            assert!(
1425                lt_bytes[offset..offset + chunk_size]
1426                    .iter()
1427                    .all(|&b| b == i as u8),
1428                "data mismatch in chunk {i}"
1429            );
1430        }
1431    }
1432
1433    // --- Written-phase cleanup ---
1434
1435    /// When a large-object overwrite commits in HV but its response is lost, the guard drops in
1436    /// `Written` phase. Cleanup must read HV to determine the CAS outcome, then delete whichever
1437    /// LT blob is no longer referenced — here the old one, since the new tombstone committed.
1438    #[tokio::test]
1439    async fn written_cleanup_after_lost_cas_response() {
1440        let (storage, hv, lt, log) = make_tiered_storage();
1441        let id = make_id("obj");
1442
1443        // First put: establishes tombstone
1444        let payload = vec![0xAAu8; 2 * 1024 * 1024];
1445        storage
1446            .put_object(&id, &Metadata::default(), stream::single(payload.clone()))
1447            .await
1448            .unwrap();
1449        let tombstone1 = hv.get(&id).expect_tombstone().target;
1450
1451        // Second put: Updates tombstone but fails immediately after committing
1452        let broken_storage = TieredStorage::new(
1453            Box::new(TestBackend::with_inner(hv.clone(), FailCas(true))),
1454            Box::new(lt.clone()),
1455            Box::new(log.clone()),
1456        );
1457        broken_storage
1458            .put_object(&id, &Metadata::default(), stream::single(payload.clone()))
1459            .await
1460            .unwrap_err(); // must fail
1461        let tombstone2 = hv.get(&id).expect_tombstone().target;
1462        assert_ne!(tombstone1, tombstone2);
1463
1464        // The first tombstone's target should be cleaned up, but the second should remain.
1465        broken_storage.join().await;
1466        lt.get(&tombstone1).expect_not_found();
1467        lt.get(&tombstone2).expect_object();
1468
1469        // Now delete the new object with the same tombstone failure
1470        broken_storage.delete_object(&id).await.unwrap_err();
1471        hv.get(&id).expect_not_found();
1472        broken_storage.join().await;
1473        lt.get(&tombstone2).expect_not_found();
1474
1475        // Create a fresh large object
1476        let id = make_id("obj2");
1477        storage
1478            .put_object(&id, &Metadata::default(), stream::single(payload.clone()))
1479            .await
1480            .unwrap();
1481        let tombstone3 = hv.get(&id).expect_tombstone().target;
1482
1483        // Overwrite it with a small object and check again for cleanup
1484        broken_storage
1485            .put_object(&id, &Metadata::default(), stream::single(&b"small"[..]))
1486            .await
1487            .unwrap_err(); // must fail
1488        hv.get(&id).expect_object();
1489        broken_storage.join().await;
1490        lt.get(&tombstone3).expect_not_found();
1491    }
1492
1493    // --- ChangeGuard drop safety tests ---
1494
1495    /// Dropping a guard outside any tokio runtime must not panic.
1496    #[test]
1497    fn guard_dropped_outside_runtime_does_not_panic() {
1498        let manager = ChangeManager::new(
1499            Box::new(InMemoryBackend::new("hv")),
1500            Box::new(InMemoryBackend::new("lt")),
1501            Box::new(NoopChangeLog),
1502        );
1503
1504        let change = Change {
1505            id: make_id("object-key"),
1506            new: Some(make_id("cleanup-target")),
1507            old: None,
1508            cleanup_after: None,
1509        };
1510
1511        // Build the guard inside a temporary runtime, then let the runtime drop
1512        // so that no tokio context is active when the guard drops.
1513        let guard = {
1514            let rt = tokio::runtime::Runtime::new().unwrap();
1515            rt.block_on(manager.record(change)).unwrap()
1516        };
1517
1518        drop(guard); // Must not panic.
1519    }
1520
1521    /// `join` blocks until all in-flight guards have completed cleanup.
1522    ///
1523    /// Time is advanced manually so the test runs at virtual speed. The guard
1524    /// completes after 10 s; `join` must still be waiting at 9 s and done by 11 s.
1525    #[tokio::test(start_paused = true)]
1526    async fn join_waits_for_cleanup_to_complete() {
1527        let (storage, _hv, _lt, _) = make_tiered_storage();
1528        let change = Change {
1529            id: make_id("object-key"),
1530            new: None,
1531            old: None,
1532            cleanup_after: None,
1533        };
1534        let mut guard = storage.record_change(change).await.unwrap();
1535
1536        tokio::spawn(async move {
1537            tokio::time::sleep(Duration::from_secs(10)).await;
1538            guard.advance(ChangePhase::Completed);
1539            drop(guard);
1540        });
1541
1542        let join_future = tokio::spawn(async move { storage.join().await });
1543
1544        tokio::time::sleep(Duration::from_secs(9)).await;
1545        assert!(!join_future.is_finished(), "finished before guard dropped");
1546
1547        tokio::time::sleep(Duration::from_secs(2)).await;
1548        assert!(join_future.is_finished(), "finish after guard drops");
1549    }
1550
1551    // --- Changelog integration tests ---
1552
1553    /// LT backend hook that completes the write, then pauses until resumed.
1554    ///
1555    /// Lets tests cancel the owning future after the blob is committed but
1556    /// before the HV tombstone is set.
1557    #[derive(Clone, Debug)]
1558    struct PauseAfterPut {
1559        paused: Arc<tokio::sync::Notify>,
1560        resume: Arc<tokio::sync::Notify>,
1561    }
1562
1563    #[async_trait::async_trait]
1564    impl Hooks for PauseAfterPut {
1565        async fn put_object(
1566            &self,
1567            inner: &InMemoryBackend,
1568            id: &ObjectId,
1569            metadata: &Metadata,
1570            stream: ClientStream,
1571        ) -> Result<PutResponse> {
1572            inner.put_object(id, metadata, stream).await?;
1573            self.paused.notify_one();
1574            self.resume.notified().await;
1575            Ok(())
1576        }
1577    }
1578
1579    /// When a future is cancelled after the LT write but before the HV tombstone is set,
1580    /// the `ChangeGuard` cleans up the orphaned LT blob and removes the log entry.
1581    #[tokio::test]
1582    async fn dropped_future_triggers_cleanup_and_log_entry_removed() {
1583        let paused = Arc::new(tokio::sync::Notify::new());
1584        let hooks = PauseAfterPut {
1585            paused: Arc::clone(&paused),
1586            resume: Arc::new(tokio::sync::Notify::new()),
1587        };
1588
1589        let lt_inner = InMemoryBackend::new("lt");
1590        let log = InMemoryChangeLog::default();
1591        let storage = TieredStorage::new(
1592            Box::new(InMemoryBackend::new("hv")),
1593            Box::new(TestBackend::with_inner(lt_inner.clone(), hooks)),
1594            Box::new(log.clone()),
1595        );
1596
1597        let id = make_id("drop-test");
1598        let metadata = Metadata::default();
1599        let payload = vec![0xABu8; 2 * 1024 * 1024]; // 2 MiB → long-term path
1600
1601        // Drive the put until the LT write commits, then cancel before the HV tombstone is set.
1602        tokio::select! {
1603            result = storage.put_object(&id, &metadata, stream::single(payload)) => {
1604                panic!("expected put to pause before completing, got: {result:?}");
1605            }
1606            _ = paused.notified() => {
1607                // LT blob stored; cancelling drops the guard in Recorded phase.
1608            }
1609        }
1610
1611        // ChangeGuard dropped → background cleanup task spawned; wait for it.
1612        storage.join().await;
1613
1614        // The orphaned LT blob must have been deleted.
1615        assert!(lt_inner.is_empty(), "orphaned LT blob was not cleaned up");
1616
1617        // The log entry must be gone once cleanup completes.
1618        let entries = log.scan().await.unwrap();
1619        assert!(
1620            entries.is_empty(),
1621            "changelog entry not removed after cleanup"
1622        );
1623    }
1624
1625    // --- Multipart upload ---
1626
1627    #[test]
1628    fn multipart_upload_id_roundtrip() {
1629        let id = TieredUploadId {
1630            revision: "my-key/01924a6f-7e28-7b9a-9c1d-abcdef123456".into(),
1631            upload_id: UploadId::new("upstream-upload-id-abc".into()).unwrap(),
1632        };
1633        let encoded: UploadId = id.clone().try_into().unwrap();
1634        let decoded: TieredUploadId = (&encoded.clone()).try_into().unwrap();
1635        assert_eq!(decoded, id);
1636    }
1637
1638    #[tokio::test]
1639    async fn multipart_single_part_roundtrip() {
1640        let (storage, hv, lt, _) = make_tiered_storage();
1641        let id = make_id("mp-single");
1642        let metadata = Metadata {
1643            content_type: "application/octet-stream".into(),
1644            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1645            ..Metadata::default()
1646        };
1647        let payload = vec![0xABu8; 2 * 1024 * 1024]; // 2 MiB
1648
1649        let upload_id = storage.initiate_multipart(&id, &metadata).await.unwrap();
1650
1651        let etag = storage
1652            .upload_part(
1653                &id,
1654                &upload_id,
1655                NonZeroU32::new(1).unwrap(),
1656                payload.len() as u64,
1657                None,
1658                stream::single(payload.clone()),
1659            )
1660            .await
1661            .unwrap();
1662
1663        let error = storage
1664            .complete_multipart(
1665                &id,
1666                &upload_id,
1667                vec![CompletedPart {
1668                    part_number: NonZeroU32::new(1).unwrap(),
1669                    etag,
1670                }],
1671            )
1672            .await
1673            .unwrap();
1674        assert!(
1675            error.is_none(),
1676            "complete_multipart returned error: {error:?}"
1677        );
1678
1679        // get_object should follow the tombstone and return the payload.
1680        let (got_meta, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
1681        let body = stream::read_to_vec(s).await.unwrap();
1682        assert_eq!(body, payload);
1683        assert_eq!(got_meta.content_type, "application/octet-stream");
1684
1685        // HV should have a tombstone, LT should have the object at the physical key.
1686        let tombstone = hv.get(&id).expect_tombstone();
1687        assert!(
1688            tombstone.target.key().starts_with(id.key()),
1689            "tombstone target should be a revision key"
1690        );
1691        lt.get(&tombstone.target).expect_object();
1692    }
1693
1694    #[tokio::test]
1695    async fn multipart_upload() {
1696        let (storage, _hv, _lt, _) = make_tiered_storage();
1697        let id = make_id("multipart");
1698
1699        let upload_id = storage
1700            .initiate_multipart(&id, &Metadata::default())
1701            .await
1702            .unwrap();
1703
1704        let part1 = vec![0xAAu8; 512 * 1024];
1705        let part2 = vec![0xBBu8; 512 * 1024];
1706        let part3 = vec![0xCCu8; 512 * 1024];
1707
1708        let etag3 = storage
1709            .upload_part(
1710                &id,
1711                &upload_id,
1712                NonZeroU32::new(3).unwrap(),
1713                part3.len() as u64,
1714                None,
1715                stream::single(part3.clone()),
1716            )
1717            .await
1718            .unwrap();
1719        let etag2 = storage
1720            .upload_part(
1721                &id,
1722                &upload_id,
1723                NonZeroU32::new(2).unwrap(),
1724                part2.len() as u64,
1725                None,
1726                stream::single(part2.clone()),
1727            )
1728            .await
1729            .unwrap();
1730        let etag1 = storage
1731            .upload_part(
1732                &id,
1733                &upload_id,
1734                NonZeroU32::new(1).unwrap(),
1735                part1.len() as u64,
1736                None,
1737                stream::single(part1.clone()),
1738            )
1739            .await
1740            .unwrap();
1741
1742        let error = storage
1743            .complete_multipart(
1744                &id,
1745                &upload_id,
1746                vec![
1747                    CompletedPart {
1748                        part_number: NonZeroU32::new(1).unwrap(),
1749                        etag: etag1,
1750                    },
1751                    CompletedPart {
1752                        part_number: NonZeroU32::new(2).unwrap(),
1753                        etag: etag2,
1754                    },
1755                    CompletedPart {
1756                        part_number: NonZeroU32::new(3).unwrap(),
1757                        etag: etag3,
1758                    },
1759                ],
1760            )
1761            .await
1762            .unwrap();
1763        assert!(error.is_none());
1764
1765        let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
1766        let body = stream::read_to_vec(s).await.unwrap();
1767
1768        let mut expected = Vec::new();
1769        expected.extend_from_slice(&part1);
1770        expected.extend_from_slice(&part2);
1771        expected.extend_from_slice(&part3);
1772        assert_eq!(body, expected);
1773    }
1774
1775    #[tokio::test]
1776    async fn multipart_abort() {
1777        let (storage, hv, _lt, _) = make_tiered_storage();
1778        let id = make_id("mp-abort");
1779
1780        let upload_id = storage
1781            .initiate_multipart(&id, &Metadata::default())
1782            .await
1783            .unwrap();
1784
1785        // Upload a part then abort.
1786        let payload = vec![0xABu8; 100];
1787        storage
1788            .upload_part(
1789                &id,
1790                &upload_id,
1791                NonZeroU32::new(1).unwrap(),
1792                payload.len() as u64,
1793                None,
1794                stream::single(payload),
1795            )
1796            .await
1797            .unwrap();
1798
1799        storage.abort_multipart(&id, &upload_id).await.unwrap();
1800
1801        // No tombstone should have been written.
1802        hv.get(&id).expect_not_found();
1803
1804        // The object should not be reachable.
1805        assert!(storage.get_object(&id, None).await.unwrap().is_none());
1806    }
1807
1808    #[tokio::test]
1809    async fn multipart_list_parts() {
1810        let (storage, _hv, _lt, _) = make_tiered_storage();
1811        let id = make_id("mp-list");
1812
1813        let upload_id = storage
1814            .initiate_multipart(&id, &Metadata::default())
1815            .await
1816            .unwrap();
1817
1818        let part1 = vec![0xAAu8; 100];
1819        let part2 = vec![0xBBu8; 200];
1820        storage
1821            .upload_part(
1822                &id,
1823                &upload_id,
1824                NonZeroU32::new(1).unwrap(),
1825                part1.len() as u64,
1826                None,
1827                stream::single(part1),
1828            )
1829            .await
1830            .unwrap();
1831        storage
1832            .upload_part(
1833                &id,
1834                &upload_id,
1835                NonZeroU32::new(2).unwrap(),
1836                part2.len() as u64,
1837                None,
1838                stream::single(part2),
1839            )
1840            .await
1841            .unwrap();
1842
1843        let resp = storage
1844            .list_parts(&id, &upload_id, None, None)
1845            .await
1846            .unwrap();
1847        assert_eq!(resp.parts.len(), 2);
1848        assert_eq!(resp.parts[0].part_number.get(), 1);
1849        assert_eq!(resp.parts[0].size, 100);
1850        assert_eq!(resp.parts[1].part_number.get(), 2);
1851        assert_eq!(resp.parts[1].size, 200);
1852    }
1853
1854    #[tokio::test]
1855    async fn multipart_overwrites_existing_tombstone() {
1856        let (storage, hv, lt, _) = make_tiered_storage();
1857        let id = make_id("mp-overwrite");
1858
1859        // Put a large object via the normal path.
1860        let payload1 = vec![0xAAu8; 2 * 1024 * 1024];
1861        storage
1862            .put_object(&id, &Metadata::default(), stream::single(payload1))
1863            .await
1864            .unwrap();
1865        let old_lt_id = hv.get(&id).expect_tombstone().target;
1866
1867        // Overwrite via multipart.
1868        let upload_id = storage
1869            .initiate_multipart(&id, &Metadata::default())
1870            .await
1871            .unwrap();
1872
1873        let payload2 = vec![0xBBu8; 2 * 1024 * 1024];
1874        let etag = storage
1875            .upload_part(
1876                &id,
1877                &upload_id,
1878                NonZeroU32::new(1).unwrap(),
1879                payload2.len() as u64,
1880                None,
1881                stream::single(payload2.clone()),
1882            )
1883            .await
1884            .unwrap();
1885
1886        // The multipart upload is not finalized, so the tombstone still points to the old
1887        // revision.
1888        let lt_id = hv.get(&id).expect_tombstone().target;
1889        assert_eq!(old_lt_id, lt_id);
1890
1891        let error = storage
1892            .complete_multipart(
1893                &id,
1894                &upload_id,
1895                vec![CompletedPart {
1896                    part_number: NonZeroU32::new(1).unwrap(),
1897                    etag,
1898                }],
1899            )
1900            .await
1901            .unwrap();
1902        assert!(error.is_none());
1903
1904        // Now the upload has been finalized, so the new tombstone points to the new revision.
1905        let new_lt_id = hv.get(&id).expect_tombstone().target;
1906        assert_ne!(old_lt_id, new_lt_id);
1907
1908        // Wait for background cleanup.
1909        storage.join().await;
1910
1911        // Old revision should be cleaned up.
1912        lt.get(&old_lt_id).expect_not_found();
1913        lt.get(&new_lt_id).expect_object();
1914
1915        // Assert the contents of the new revision.
1916        let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
1917        let body = stream::read_to_vec(s).await.unwrap();
1918        assert_eq!(body, payload2);
1919    }
1920
1921    // --- Multipart completion failure handling (consistency, retries, delayed cleanup) ---
1922
1923    /// Assembles the blob via `complete_multipart`, but returns an error to simulate a network
1924    /// failure on the response path. Also fails `get_metadata` so the tiered layer cannot
1925    /// recover by detecting the already-assembled blob.
1926    #[derive(Debug)]
1927    struct CompleteMultipartButReturnError;
1928
1929    #[async_trait::async_trait]
1930    impl Hooks for CompleteMultipartButReturnError {
1931        async fn complete_multipart(
1932            &self,
1933            inner: &InMemoryBackend,
1934            id: &ObjectId,
1935            upload_id: &UploadId,
1936            parts: Vec<CompletedPart>,
1937        ) -> Result<CompleteMultipartResponse> {
1938            inner
1939                .complete_multipart(id, upload_id, parts)
1940                .await
1941                .unwrap();
1942            Err(Error::Io(std::io::Error::new(
1943                std::io::ErrorKind::TimedOut,
1944                "simulated network error on complete_multipart",
1945            )))
1946        }
1947
1948        async fn get_metadata(
1949            &self,
1950            _inner: &InMemoryBackend,
1951            _id: &ObjectId,
1952        ) -> Result<MetadataResponse> {
1953            Err(Error::Io(std::io::Error::new(
1954                std::io::ErrorKind::TimedOut,
1955                "simulated network error on get_metadata",
1956            )))
1957        }
1958    }
1959
1960    /// `complete_multipart` on the inner LT backend assembles the blob successfully, but both
1961    /// `complete_multipart` and `get_metadata` return errors, so the tiered layer cannot finalize.
1962    /// After `MULTIPART_COMPLETE_CLEANUP_DELAY`, `ChangeLog` recovery deletes the orphaned blob.
1963    #[tokio::test]
1964    async fn cleans_up_orphan_after_failed_multipart_complete() {
1965        let hv = InMemoryBackend::new("hv");
1966        let lt_inner = InMemoryBackend::new("lt");
1967        let log = InMemoryChangeLog::default();
1968        let storage = TieredStorage::new(
1969            Box::new(hv.clone()),
1970            Box::new(TestBackend::with_inner(
1971                lt_inner.clone(),
1972                CompleteMultipartButReturnError {},
1973            )),
1974            Box::new(log.clone()),
1975        );
1976
1977        let id = make_id("mp-orphan");
1978        let upload_id = storage
1979            .initiate_multipart(&id, &Metadata::default())
1980            .await
1981            .unwrap();
1982
1983        let tiered_id: TieredUploadId = (&upload_id).try_into().unwrap();
1984        let physical = ObjectId {
1985            context: id.context.clone(),
1986            key: tiered_id.revision,
1987        };
1988
1989        let payload = vec![0xABu8; 2 * 1024 * 1024];
1990        let etag = storage
1991            .upload_part(
1992                &id,
1993                &upload_id,
1994                NonZeroU32::new(1).unwrap(),
1995                payload.len() as u64,
1996                None,
1997                stream::single(payload),
1998            )
1999            .await
2000            .unwrap();
2001
2002        let result = storage
2003            .complete_multipart(
2004                &id,
2005                &upload_id,
2006                vec![CompletedPart {
2007                    part_number: NonZeroU32::new(1).unwrap(),
2008                    etag,
2009                }],
2010            )
2011            .await;
2012        assert!(result.is_err());
2013        storage.join().await;
2014
2015        // The LT blob is orphaned, and no cleanup has been performed (yet), due to the guard being
2016        // dropped while in the `Assembling` state.
2017        lt_inner.get(&physical).expect_object();
2018        hv.get(&id).expect_not_found();
2019
2020        // Simulate the passage of time and run recovery.
2021        log.expire_all();
2022        let manager = ChangeManager::new(
2023            Box::new(hv.clone()),
2024            Box::new(lt_inner.clone()),
2025            Box::new(log.clone()),
2026        );
2027        manager.recover().await.unwrap();
2028
2029        // The orphaned LT blob has been cleaned up.
2030        lt_inner.get(&physical).expect_not_found();
2031        // The change has been removed from the log.
2032        let remaining = log.scan().await.unwrap();
2033        assert!(remaining.is_empty());
2034    }
2035
2036    #[derive(Debug)]
2037    struct FailOnFirstCompleteMultipartAttempt {
2038        attempt: Mutex<u32>,
2039    }
2040
2041    impl FailOnFirstCompleteMultipartAttempt {
2042        fn new() -> Self {
2043            Self {
2044                attempt: Mutex::new(0),
2045            }
2046        }
2047    }
2048
2049    #[async_trait::async_trait]
2050    impl Hooks for FailOnFirstCompleteMultipartAttempt {
2051        async fn complete_multipart(
2052            &self,
2053            inner: &InMemoryBackend,
2054            id: &ObjectId,
2055            upload_id: &UploadId,
2056            parts: Vec<CompletedPart>,
2057        ) -> Result<CompleteMultipartResponse> {
2058            let mut attempt = self.attempt.lock().await;
2059            *attempt += 1;
2060            if *attempt == 1 {
2061                Err(Error::Io(std::io::Error::new(
2062                    std::io::ErrorKind::TimedOut,
2063                    "simulated network error",
2064                )))
2065            } else {
2066                Ok(inner
2067                    .complete_multipart(id, upload_id, parts)
2068                    .await
2069                    .unwrap())
2070            }
2071        }
2072    }
2073
2074    /// The first attempt to `complete_multipart` fails, which generates a `Change` entry.
2075    /// The second call succeeds.
2076    /// When it's time to clean up, nothing is deleted, as the `complete_multipart` eventually went
2077    /// through before the cleanup deadline.
2078    #[tokio::test]
2079    async fn multipart_complete_succeeds_on_retry_and_leaves_state_consistent() {
2080        let hv = InMemoryBackend::new("hv");
2081        let lt_inner = InMemoryBackend::new("lt");
2082        let log = InMemoryChangeLog::default();
2083        let storage = TieredStorage::new(
2084            Box::new(hv.clone()),
2085            Box::new(TestBackend::with_inner(
2086                lt_inner.clone(),
2087                FailOnFirstCompleteMultipartAttempt::new(),
2088            )),
2089            Box::new(log.clone()),
2090        );
2091
2092        let id = make_id("mp-retry");
2093        let upload_id = storage
2094            .initiate_multipart(&id, &Metadata::default())
2095            .await
2096            .unwrap();
2097
2098        let tiered_id: TieredUploadId = (&upload_id).try_into().unwrap();
2099        let physical = ObjectId {
2100            context: id.context.clone(),
2101            key: tiered_id.revision,
2102        };
2103
2104        let payload = vec![0xABu8; 2 * 1024 * 1024];
2105        let etag = storage
2106            .upload_part(
2107                &id,
2108                &upload_id,
2109                NonZeroU32::new(1).unwrap(),
2110                payload.len() as u64,
2111                None,
2112                stream::single(payload.clone()),
2113            )
2114            .await
2115            .unwrap();
2116
2117        // The first `complete_multipart` call fails.
2118        let result = storage
2119            .complete_multipart(
2120                &id,
2121                &upload_id,
2122                vec![CompletedPart {
2123                    part_number: NonZeroU32::new(1).unwrap(),
2124                    etag: etag.clone(),
2125                }],
2126            )
2127            .await;
2128        assert!(result.is_err());
2129        storage.join().await;
2130
2131        // The second `complete_multipart` call succeeds.
2132        let result = storage
2133            .complete_multipart(
2134                &id,
2135                &upload_id,
2136                vec![CompletedPart {
2137                    part_number: NonZeroU32::new(1).unwrap(),
2138                    etag,
2139                }],
2140            )
2141            .await;
2142        assert!(result.is_ok());
2143        storage.join().await;
2144
2145        // The object is there.
2146        let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
2147        let body = stream::read_to_vec(s).await.unwrap();
2148        assert_eq!(body, payload);
2149
2150        // Simulate the passage of time and run recovery.
2151        log.expire_all();
2152        let manager = ChangeManager::new(
2153            Box::new(hv.clone()),
2154            Box::new(lt_inner.clone()),
2155            Box::new(log.clone()),
2156        );
2157        manager.recover().await.unwrap();
2158
2159        // The LT blob has not been cleaned up, as the write eventually went through.
2160        lt_inner.get(&physical).expect_object();
2161        // The tombstone still points to the blob.
2162        let tombstone = hv.get(&id).expect_tombstone();
2163        assert_eq!(tombstone.target, physical);
2164        // The change has been removed from the log.
2165        let remaining = log.scan().await.unwrap();
2166        assert!(remaining.is_empty());
2167
2168        // The object is still there after recovery.
2169        let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
2170        let body = stream::read_to_vec(s).await.unwrap();
2171        assert_eq!(body, payload);
2172    }
2173
2174    #[derive(Debug)]
2175    struct FailOnFirstGetMetadataAttempt {
2176        attempt: Mutex<u32>,
2177    }
2178
2179    impl FailOnFirstGetMetadataAttempt {
2180        fn new() -> Self {
2181            Self {
2182                attempt: Mutex::new(0),
2183            }
2184        }
2185    }
2186
2187    #[async_trait::async_trait]
2188    impl Hooks for FailOnFirstGetMetadataAttempt {
2189        async fn get_metadata(
2190            &self,
2191            inner: &InMemoryBackend,
2192            id: &ObjectId,
2193        ) -> Result<MetadataResponse> {
2194            let mut attempt = self.attempt.lock().await;
2195            *attempt += 1;
2196            if *attempt == 1 {
2197                Err(Error::Io(std::io::Error::new(
2198                    std::io::ErrorKind::TimedOut,
2199                    "simulated network error",
2200                )))
2201            } else {
2202                inner.get_metadata(id).await
2203            }
2204        }
2205    }
2206
2207    /// The first attempt to `complete_multipart` succeeds on the LT backend, but the subsequent
2208    /// `get_metadata` call fails with a network error, causing the overall `complete_multipart`
2209    /// to fail. The second call retries and succeeds (the LT object already exists from the first
2210    /// attempt).
2211    /// When it's time to clean up, nothing is deleted, as the `complete_multipart` eventually went
2212    /// through before the cleanup deadline.
2213    #[tokio::test]
2214    async fn multipart_complete_succeeds_on_retry_if_get_metadata_errs_and_leaves_state_consistent()
2215    {
2216        let hv = InMemoryBackend::new("hv");
2217        let lt_inner = InMemoryBackend::new("lt");
2218        let log = InMemoryChangeLog::default();
2219        let storage = TieredStorage::new(
2220            Box::new(hv.clone()),
2221            Box::new(TestBackend::with_inner(
2222                lt_inner.clone(),
2223                FailOnFirstGetMetadataAttempt::new(),
2224            )),
2225            Box::new(log.clone()),
2226        );
2227
2228        let id = make_id("mp-retry-meta");
2229        let upload_id = storage
2230            .initiate_multipart(&id, &Metadata::default())
2231            .await
2232            .unwrap();
2233
2234        let tiered_id: TieredUploadId = (&upload_id).try_into().unwrap();
2235        let physical = ObjectId {
2236            context: id.context.clone(),
2237            key: tiered_id.revision,
2238        };
2239
2240        let payload = vec![0xABu8; 2 * 1024 * 1024];
2241        let etag = storage
2242            .upload_part(
2243                &id,
2244                &upload_id,
2245                NonZeroU32::new(1).unwrap(),
2246                payload.len() as u64,
2247                None,
2248                stream::single(payload.clone()),
2249            )
2250            .await
2251            .unwrap();
2252
2253        // The first `complete_multipart` call fails (get_metadata network error), even though it
2254        // internally creates the LT blob.
2255        let result = storage
2256            .complete_multipart(
2257                &id,
2258                &upload_id,
2259                vec![CompletedPart {
2260                    part_number: NonZeroU32::new(1).unwrap(),
2261                    etag: etag.clone(),
2262                }],
2263            )
2264            .await;
2265        assert!(result.is_err());
2266        storage.join().await;
2267
2268        // The second `complete_multipart` call succeeds.
2269        let result = storage
2270            .complete_multipart(
2271                &id,
2272                &upload_id,
2273                vec![CompletedPart {
2274                    part_number: NonZeroU32::new(1).unwrap(),
2275                    etag,
2276                }],
2277            )
2278            .await;
2279        assert!(result.is_ok());
2280        storage.join().await;
2281
2282        // The object is there.
2283        let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
2284        let body = stream::read_to_vec(s).await.unwrap();
2285        assert_eq!(body, payload);
2286
2287        // Simulate the passage of time and run recovery.
2288        log.expire_all();
2289        let manager = ChangeManager::new(
2290            Box::new(hv.clone()),
2291            Box::new(lt_inner.clone()),
2292            Box::new(log.clone()),
2293        );
2294        manager.recover().await.unwrap();
2295
2296        // The LT blob has not been cleaned up, as the write eventually went through.
2297        lt_inner.get(&physical).expect_object();
2298        // The tombstone still points to the blob.
2299        let tombstone = hv.get(&id).expect_tombstone();
2300        assert_eq!(tombstone.target, physical);
2301        // The change has been removed from the log.
2302        let remaining = log.scan().await.unwrap();
2303        assert!(remaining.is_empty());
2304
2305        // The object is there after recovery.
2306        let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
2307        let body = stream::read_to_vec(s).await.unwrap();
2308        assert_eq!(body, payload);
2309    }
2310}