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