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