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