Skip to main content

objectstore_service/
service.rs

1//! Core storage service and configuration.
2//!
3//! [`StorageService`] is the main entry point for storing and retrieving
4//! objects. Each operation runs in a separate tokio task for panic isolation.
5//!
6//! Callers supply an access timestamp, normally the HTTP request start time.
7//! It stays fixed across admission, backend calls, and TTI calculations. Initial
8//! creation and expiry are already resolved in the supplied metadata. Background
9//! renewals retain the read-derived deadline but check liveness at worker start.
10//!
11//! See the [crate-level documentation](crate) for full architecture details.
12
13use std::future::Future;
14use std::num::NonZeroU64;
15use std::sync::Arc;
16
17use objectstore_types::metadata::Metadata;
18use objectstore_types::range::{ByteRange, ContentRange};
19use objectstore_types::resumable::{SessionToken as EncryptedSessionToken, UploadProgress};
20use objectstore_types::time::Timestamp;
21
22use crate::backend::common::Backend;
23use crate::backend::counting::CountingBackend;
24use crate::background::RenewalScheduler;
25use crate::concurrency::ConcurrencyLimiter;
26use crate::encryption::Cipher;
27use crate::error::{ErrorKind, Result, ResultExt as _};
28use crate::id::{ObjectContext, ObjectId};
29use crate::multipart::{
30    AbortMultipartResponse, CompleteMultipartResponse, CompletedPart, InitiateMultipartResponse,
31    ListPartsResponse, PartNumber, UploadId, UploadPartResponse,
32};
33use crate::resumable::{BackendToken, SessionToken};
34use crate::stream::{ClientStream, PayloadStream};
35use crate::streaming::StreamExecutor;
36
37/// Service response for [`StorageService::get_object`].
38pub type GetResponse = Option<(Metadata, Option<ContentRange>, PayloadStream)>;
39/// Service response for [`StorageService::get_metadata`].
40pub type MetadataResponse = Option<Metadata>;
41/// Service response for [`StorageService::insert_object`].
42pub type InsertResponse = ObjectId;
43/// Service response for [`StorageService::delete_object`].
44pub type DeleteResponse = ();
45
46/// Default concurrency limit for [`StorageService`].
47///
48/// This value is used when no explicit limiter is set via
49/// [`StorageService::with_concurrency`].
50pub const DEFAULT_CONCURRENCY_LIMIT: u32 = 500;
51
52/// Default number of TTI renewals that may wait for background processing.
53pub const DEFAULT_BACKGROUND_QUEUE_LIMIT: usize = 1_000;
54
55/// Asynchronous storage service wrapping a single [`Backend`].
56///
57/// `StorageService` is the main entry point for storing and retrieving objects.
58/// It delegates all storage operations to the backend supplied at construction,
59/// adding task spawning, panic isolation, and a concurrency limit on top.
60///
61/// The typical backend is [`TieredStorage`](crate::backend::tiered::TieredStorage),
62/// which provides size-based routing to high-volume and long-term backends along
63/// with redirect tombstone management. Any type implementing [`Backend`] can be used.
64///
65/// # Lifecycle
66///
67/// After construction, call [`start`](StorageService::start) to start the
68/// service's background processes.
69///
70/// # Run-to-Completion and Panic Isolation
71///
72/// Each operation runs to completion even if the caller is cancelled (e.g., on
73/// client disconnect). This ensures that multi-step operations in the backend
74/// are never left partially applied. Post-commit cleanup (e.g. deleting
75/// unreferenced long-term blobs) runs in background tasks so callers are not
76/// blocked. Call [`join`](StorageService::join) during shutdown to wait for
77/// outstanding cleanup. Operations are also isolated from panics in backend
78/// code — a failure in one operation does not bring down other in-flight work.
79///
80/// # Concurrency Limit
81///
82/// A [`ConcurrencyLimiter`] caps the number of in-flight backend operations.
83/// Pass a custom limiter via
84/// [`with_concurrency`](StorageService::with_concurrency); without one the
85/// default is [`DEFAULT_CONCURRENCY_LIMIT`] permits with no queue.
86#[derive(Clone, Debug)]
87pub struct StorageService {
88    inner: Arc<dyn Backend>,
89    concurrency: ConcurrencyLimiter,
90    renewals: RenewalScheduler,
91    cipher: Arc<Cipher>,
92}
93
94impl StorageService {
95    /// Creates a new `StorageService` wrapping the given backend.
96    ///
97    /// The backend is wrapped in a [`CountingBackend`] which increments a COGS usage counter for
98    /// each operation run. Single-object operations served directly by `StorageService` are covered
99    /// as we batched operations served by [`StreamExecutor`]. See
100    /// [`backend::counting`](crate::backend::counting) for details.
101    pub fn new(backend: Box<dyn Backend>, cipher: Cipher) -> Self {
102        let inner: Arc<dyn Backend> = Arc::new(CountingBackend::new(backend));
103        let concurrency = ConcurrencyLimiter::new(DEFAULT_CONCURRENCY_LIMIT);
104        Self {
105            inner: Arc::clone(&inner),
106            concurrency: concurrency.clone(),
107            renewals: RenewalScheduler::new(inner, concurrency, DEFAULT_BACKGROUND_QUEUE_LIMIT),
108            cipher: Arc::new(cipher),
109        }
110    }
111
112    /// Replaces the default concurrency limiter.
113    ///
114    /// Must be called before [`start`](Self::start). Without this, the
115    /// service uses a limiter with [`DEFAULT_CONCURRENCY_LIMIT`] permits
116    /// and no queue.
117    pub fn with_concurrency(mut self, limiter: ConcurrencyLimiter) -> Self {
118        self.renewals.set_concurrency(limiter.clone());
119        self.concurrency = limiter;
120        self
121    }
122
123    /// Replaces the default background expiry-renewal queue capacity.
124    pub fn with_background_queue_limit(mut self, limit: usize) -> Self {
125        self.renewals.set_capacity(limit);
126        self
127    }
128
129    /// Returns a reference to the concurrency limiter.
130    pub fn concurrency_limiter(&self) -> &ConcurrencyLimiter {
131        &self.concurrency
132    }
133
134    /// Returns the number of backend tasks currently running.
135    pub fn tasks_running(&self) -> u32 {
136        self.concurrency.used_permits()
137    }
138
139    /// Returns the configured limit for concurrent backend tasks.
140    pub fn tasks_limit(&self) -> u32 {
141        self.concurrency.total_permits()
142    }
143
144    /// Prepares to stream multiple operations concurrently against this service.
145    ///
146    /// Each operation acquires a bulk permit individually via
147    /// [`ConcurrencyLimiter::acquire_bulk`], which caps bulk traffic at a
148    /// configurable percentage of execution slots while allowing operations
149    /// to queue for permits instead of requiring upfront reservation.
150    pub fn stream(&self) -> StreamExecutor {
151        StreamExecutor::new(
152            Arc::clone(&self.inner),
153            self.concurrency.clone(),
154            self.renewals.clone(),
155        )
156    }
157
158    /// Starts background processes for the storage service.
159    ///
160    /// At startup, this tracks the following gauges:
161    ///
162    ///  - `service.concurrency.limit`: concurrent task execution slots
163    ///  - `service.concurrency.queue_limit`: queue size for waiting tasks
164    ///  - `service.concurrency.bulk_limit`: concurrent task execution slots for bulk operations
165    ///
166    /// Also spawns tasks that emit runtime gauges once per second:
167    ///  - `service.concurrency.in_use`: currently running tasks
168    ///  - `service.concurrency.queued`: currently queued tasks
169    ///  - `service.concurrency.bulk_in_use`: currently running bulk tasks
170    ///  - `service.expiry_renewal.queued`: expiry renewals waiting for the background worker
171    pub fn start(&mut self) {
172        self.renewals.start();
173
174        let concurrency = self.concurrency.clone();
175        objectstore_metrics::gauge!("service.concurrency.limit" = concurrency.total_permits());
176        objectstore_metrics::gauge!("service.concurrency.queue_limit" = concurrency.total_queue());
177        objectstore_metrics::gauge!("service.concurrency.bulk_limit" = concurrency.total_bulk());
178
179        tokio::spawn(async move {
180            concurrency
181                .run_emitter(|stats| async move {
182                    objectstore_metrics::gauge!("service.concurrency.in_use" = stats.in_use);
183                    objectstore_metrics::gauge!("service.concurrency.queued" = stats.queued);
184                    objectstore_metrics::gauge!(
185                        "service.concurrency.bulk_in_use" = stats.bulk_in_use
186                    );
187                })
188                .await;
189        });
190
191        let renewals = self.renewals.clone();
192        tokio::spawn(async move {
193            renewals
194                .run_emitter(|queued| async move {
195                    objectstore_metrics::gauge!("service.expiry_renewal.queued" = queued);
196                })
197                .await;
198        });
199    }
200
201    /// Spawns a future in a separate task and awaits its result.
202    ///
203    /// # Observability
204    ///
205    /// This tracks two metrics:
206    ///
207    /// - `service.task.start` (counter) after acquiring a permit
208    /// - `service.task.duration` (distribution) when the task completes
209    ///
210    /// Both are tagged with the given `operation` name and an `outcome`
211    /// of `"success"` or `"error"`.
212    ///
213    /// # Errors
214    ///
215    /// - `AtCapacity` if the concurrency limit is reached
216    /// - `Panic` if the spawned task panics (the panic message is captured for diagnostics)
217    /// - `Dropped` if the task is dropped before sending its result.
218    async fn spawn<T, F>(&self, operation: &'static str, f: F) -> Result<T>
219    where
220        T: Send + 'static,
221        F: Future<Output = Result<T>> + Send + 'static,
222    {
223        let timer = objectstore_metrics::timer!("service.concurrency.wait");
224        let permit = self.concurrency.acquire().await.inspect_err(|_| {
225            objectstore_metrics::count!("service.concurrency.rejected", class = "normal");
226            objectstore_log::warn!("Request rejected: service at capacity");
227        })?;
228
229        timer.record();
230        crate::concurrency::run_metered(operation, permit, f).await
231    }
232
233    /// Creates or overwrites an object.
234    ///
235    /// The object is identified by the components of an [`ObjectId`]. The
236    /// `context` is required, while the `key` can be assigned automatically if
237    /// set to `None`.
238    ///
239    /// # Run-to-completion
240    ///
241    /// Once called, the operation runs to completion even if the returned future
242    /// is dropped (e.g., on client disconnect). This guarantees that partially
243    /// written objects in the backend are never left in an inconsistent state.
244    pub async fn insert_object(
245        &self,
246        context: ObjectContext,
247        key: Option<String>,
248        metadata: Metadata,
249        stream: ClientStream,
250        access_time: Timestamp,
251    ) -> Result<InsertResponse> {
252        metadata.validate().kind(ErrorKind::InvalidMetadata)?;
253        let id = ObjectId::optional(context, key);
254        let inner = Arc::clone(&self.inner);
255        self.spawn("insert", async move {
256            inner
257                .put_object(&id, &metadata, stream, access_time)
258                .await?;
259            Ok(id)
260        })
261        .await
262    }
263
264    /// Retrieves only the metadata for an object, without the payload.
265    pub async fn get_metadata(
266        &self,
267        id: ObjectId,
268        access_time: Timestamp,
269    ) -> Result<MetadataResponse> {
270        let inner = Arc::clone(&self.inner);
271        let renewals = self.renewals.clone();
272        self.spawn("get_metadata", async move {
273            let response = inner.get_metadata(&id, access_time).await?;
274            if let Some(ref metadata) = response
275                && let Some(expire_at) = metadata.check_tti_bump(access_time)
276            {
277                renewals.schedule(id, expire_at);
278            }
279            Ok(response)
280        })
281        .await
282    }
283
284    /// Streams (part of) the contents of an object.
285    pub async fn get_object(
286        &self,
287        id: ObjectId,
288        access_time: Timestamp,
289        range: Option<ByteRange>,
290    ) -> Result<GetResponse> {
291        let inner = Arc::clone(&self.inner);
292        let renewals = self.renewals.clone();
293        self.spawn("get", async move {
294            let response = inner.get_object(&id, access_time, range).await?;
295            if let Some((ref metadata, _, _)) = response
296                && let Some(expire_at) = metadata.check_tti_bump(access_time)
297            {
298                renewals.schedule(id, expire_at);
299            }
300            Ok(response)
301        })
302        .await
303    }
304
305    /// Extends an existing TTL or TTI object's deadline.
306    pub async fn set_expiry(
307        &self,
308        id: ObjectId,
309        expire_at: Timestamp,
310        access_time: Timestamp,
311    ) -> Result<bool> {
312        let inner = Arc::clone(&self.inner);
313        self.spawn("set_expiry", async move {
314            inner.set_expiry(&id, expire_at, access_time).await
315        })
316        .await
317    }
318
319    /// Deletes an object, if it exists.
320    ///
321    /// # Run-to-completion
322    ///
323    /// Once called, the operation runs to completion even if the returned future
324    /// is dropped. This guarantees that multi-step delete sequences in the backend
325    /// are never left partially applied.
326    pub async fn delete_object(
327        &self,
328        id: ObjectId,
329        access_time: Timestamp,
330    ) -> Result<DeleteResponse> {
331        let inner = Arc::clone(&self.inner);
332        self.spawn("delete", async move {
333            inner.delete_object(&id, access_time).await
334        })
335        .await
336    }
337
338    /// Waits for all outstanding background operations to complete.
339    ///
340    /// Blocks until any pending background cleanup tasks finish, up to the
341    /// backend's configured timeout. Should be called during graceful shutdown
342    /// after the HTTP server has stopped accepting new requests.
343    pub async fn join(&self) {
344        self.renewals.join().await;
345        self.inner.join().await;
346    }
347
348    // --- Multipart upload operations ---
349
350    /// Initiates a new multipart upload.
351    pub async fn initiate_multipart(
352        &self,
353        id: ObjectId,
354        metadata: Metadata,
355    ) -> Result<InitiateMultipartResponse> {
356        metadata.validate().kind(ErrorKind::InvalidMetadata)?;
357        self.inner.as_multipart_upload_backend()?; // Fail before clone/spawn if unsupported
358        let inner = self.inner.clone();
359        self.spawn("initiate_multipart", async move {
360            inner
361                .as_multipart_upload_backend()?
362                .initiate_multipart(&id, &metadata)
363                .await
364        })
365        .await
366    }
367
368    /// Uploads a single part.
369    ///
370    /// Note that this requires a `content_length`.
371    /// This grants us the broadest and most seamless compatibility when it comes to backends.
372    /// For example, MinIO rejects `UploadPart` requests without a `Content-Length` on plain PUT
373    /// requests.
374    /// This can be worked around by using AWS SigV4 chunked streaming requests, which we could use
375    /// if one day we'll have a usecase where the client doesn't know the part length upfront.
376    pub async fn upload_part(
377        &self,
378        id: ObjectId,
379        upload_id: UploadId,
380        part_number: PartNumber,
381        content_length: u64,
382        content_md5: Option<String>,
383        body: ClientStream,
384    ) -> Result<UploadPartResponse> {
385        self.inner.as_multipart_upload_backend()?; // Fail before clone/spawn if unsupported
386        let inner = self.inner.clone();
387        self.spawn("upload_part", async move {
388            inner
389                .as_multipart_upload_backend()?
390                .upload_part(
391                    &id,
392                    &upload_id,
393                    part_number,
394                    content_length,
395                    content_md5.as_deref(),
396                    body,
397                )
398                .await
399        })
400        .await
401    }
402
403    /// Lists the parts uploaded so far.
404    pub async fn list_parts(
405        &self,
406        id: ObjectId,
407        upload_id: UploadId,
408        max_parts: Option<u32>,
409        part_number_marker: Option<PartNumber>,
410    ) -> Result<ListPartsResponse> {
411        self.inner.as_multipart_upload_backend()?; // Fail before clone/spawn if unsupported
412        let inner = self.inner.clone();
413        self.spawn("list_parts", async move {
414            inner
415                .as_multipart_upload_backend()?
416                .list_parts(&id, &upload_id, max_parts, part_number_marker)
417                .await
418        })
419        .await
420    }
421
422    /// Aborts a multipart upload.
423    pub async fn abort_multipart(
424        &self,
425        id: ObjectId,
426        upload_id: UploadId,
427    ) -> Result<AbortMultipartResponse> {
428        self.inner.as_multipart_upload_backend()?; // Fail before clone/spawn if unsupported
429        let inner = self.inner.clone();
430        self.spawn("abort_multipart", async move {
431            inner
432                .as_multipart_upload_backend()?
433                .abort_multipart(&id, &upload_id)
434                .await
435        })
436        .await
437    }
438
439    /// Finalizes a multipart upload.
440    pub async fn complete_multipart(
441        &self,
442        id: ObjectId,
443        upload_id: UploadId,
444        parts: Vec<CompletedPart>,
445        access_time: Timestamp,
446    ) -> Result<CompleteMultipartResponse> {
447        self.inner.as_multipart_upload_backend()?; // Fail before clone/spawn if unsupported
448        let inner = self.inner.clone();
449        self.spawn("complete_multipart", async move {
450            inner
451                .as_multipart_upload_backend()?
452                .complete_multipart(&id, &upload_id, parts, access_time)
453                .await
454        })
455        .await
456    }
457
458    // --- Resumable upload operations ---
459
460    /// Opens a resumable upload session for an object of `total_length` bytes.
461    ///
462    /// Returns `Ok(None)` for zero-length objects or when the backend declines resumable uploads
463    /// for this object, in which case the caller should fall back to [`Self::insert_object`].
464    pub async fn create_upload_session(
465        &self,
466        id: ObjectId,
467        metadata: Metadata,
468        total_length: u64,
469    ) -> Result<Option<EncryptedSessionToken>> {
470        let Some(total_length) = NonZeroU64::new(total_length) else {
471            return Ok(None);
472        };
473        metadata.validate().kind(ErrorKind::InvalidMetadata)?;
474        let inner = Arc::clone(&self.inner);
475        let cipher = Arc::clone(&self.cipher);
476        self.spawn("create_upload_session", async move {
477            let session = inner
478                .create_upload_session(&id, &metadata, total_length)
479                .await?;
480            session
481                .map(|backend_token| {
482                    cipher
483                        .encrypt(&SessionToken {
484                            object_id: id,
485                            backend_token,
486                        })
487                        .map(EncryptedSessionToken::new)
488                })
489                .transpose()
490        })
491        .await
492    }
493
494    fn backend_token_for(
495        &self,
496        expected_id: &ObjectId,
497        token: EncryptedSessionToken,
498    ) -> Result<BackendToken> {
499        let session: SessionToken = self
500            .cipher
501            .decrypt(token.as_bytes())
502            .map_err(|_| ErrorKind::UnknownUploadSession)?;
503        if session.object_id != *expected_id {
504            return Err(ErrorKind::UnknownUploadSession.into());
505        }
506        Ok(session.backend_token)
507    }
508
509    /// Writes a chunk of `content_length` bytes at `offset` into an open session.
510    ///
511    /// Completes the upload once the chunk carrying the last byte is persisted.
512    ///
513    /// # Run-to-completion
514    ///
515    /// Once called, the operation runs to completion even if the returned future is dropped.
516    /// This matters most for the final chunk, which completes the upload.
517    pub async fn put_chunk(
518        &self,
519        id: ObjectId,
520        token: EncryptedSessionToken,
521        offset: u64,
522        content_length: u64,
523        body: ClientStream,
524    ) -> Result<UploadProgress> {
525        let session = self.backend_token_for(&id, token)?;
526        let inner = Arc::clone(&self.inner);
527        self.spawn("put_chunk", async move {
528            inner
529                .put_chunk(&id, &session, offset, content_length, body)
530                .await
531        })
532        .await
533    }
534
535    /// Reports how far a session has progressed.
536    ///
537    /// This can observe completion after the final chunk's response was lost. A composed backend
538    /// may also finish pending publication work, so this requires write permission at the API
539    /// layer.
540    pub async fn upload_offset(
541        &self,
542        id: ObjectId,
543        token: EncryptedSessionToken,
544    ) -> Result<UploadProgress> {
545        let session = self.backend_token_for(&id, token)?;
546        let inner = Arc::clone(&self.inner);
547        self.spawn("upload_offset", async move {
548            inner.upload_offset(&id, &session).await
549        })
550        .await
551    }
552
553    /// Cancels an upload session, discarding whatever was uploaded.
554    pub async fn cancel_upload(&self, id: ObjectId, token: EncryptedSessionToken) -> Result<()> {
555        let session = self.backend_token_for(&id, token)?;
556        let inner = Arc::clone(&self.inner);
557        self.spawn("cancel_upload", async move {
558            inner.cancel_upload(&id, &session).await
559        })
560        .await
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use std::error::Error as _;
567    use std::sync::atomic::{AtomicUsize, Ordering};
568    use std::sync::{Arc, Mutex};
569    use std::time::Duration;
570
571    use bytes::BytesMut;
572    use futures_util::TryStreamExt;
573    use objectstore_types::metadata::{ExpirationPolicy, Metadata};
574    use objectstore_types::range::ByteRange;
575    use objectstore_types::scope::{Scope, Scopes};
576
577    use super::*;
578    use crate::backend::bigtable::{BigTableBackend, BigTableConfig};
579    use crate::backend::changelog::NoopChangeLog;
580    use crate::backend::common::{HighVolumeBackend, PutResponse, TieredWrite};
581    use crate::backend::gcs::{GcsBackend, GcsConfig};
582    use crate::backend::in_memory::InMemoryBackend;
583    use crate::backend::testing::{Hooks, TestBackend};
584    use crate::backend::tiered::TieredStorage;
585    use crate::change_stream::ChangeStreamFactory;
586    use crate::stream::{self, ClientStream};
587
588    #[derive(Clone, Debug, Default)]
589    struct ResumableTokenHooks {
590        seen_tokens: Arc<Mutex<Vec<String>>>,
591    }
592
593    #[async_trait::async_trait]
594    impl Hooks for ResumableTokenHooks {
595        async fn create_upload_session(
596            &self,
597            _inner: &InMemoryBackend,
598            _id: &ObjectId,
599            _metadata: &Metadata,
600            _total_length: NonZeroU64,
601        ) -> Result<Option<BackendToken>> {
602            Ok(Some("backend token".to_owned()))
603        }
604
605        async fn upload_offset(
606            &self,
607            _inner: &InMemoryBackend,
608            _id: &ObjectId,
609            token: &BackendToken,
610        ) -> Result<UploadProgress> {
611            self.seen_tokens.lock().unwrap().push(token.to_owned());
612            Ok(UploadProgress::Incomplete { offset: 0 })
613        }
614    }
615
616    fn make_context() -> ObjectContext {
617        ObjectContext {
618            usecase: "testing".into(),
619            scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
620        }
621    }
622
623    fn make_service() -> StorageService {
624        StorageService::new(
625            Box::new(InMemoryBackend::new("in-memory")),
626            Cipher::ephemeral().unwrap(),
627        )
628    }
629
630    #[tokio::test]
631    async fn insert_without_key_generates_unique_id() {
632        let service = make_service();
633
634        let id = service
635            .insert_object(
636                make_context(),
637                None,
638                Metadata::default(),
639                stream::single("auto-keyed"),
640                Timestamp::now(),
641            )
642            .await
643            .unwrap();
644
645        assert!(uuid::Uuid::parse_str(id.key()).is_ok());
646    }
647
648    #[tokio::test]
649    async fn stores_files() {
650        let service = make_service();
651
652        let key = service
653            .insert_object(
654                make_context(),
655                Some("testing".into()),
656                Metadata::default(),
657                stream::single("oh hai!"),
658                Timestamp::now(),
659            )
660            .await
661            .unwrap();
662
663        let (_metadata, _, stream) = service
664            .get_object(key, Timestamp::now(), None)
665            .await
666            .unwrap()
667            .unwrap();
668        let file_contents: BytesMut = stream.try_collect().await.unwrap();
669
670        assert_eq!(file_contents.as_ref(), b"oh hai!");
671    }
672
673    #[tokio::test]
674    async fn works_with_gcs() {
675        let config = GcsConfig {
676            endpoint: Some("http://localhost:8087".into()),
677            bucket: "test-bucket".into(), // aligned with the env var in devservices and CI
678            cogs: None,
679        };
680
681        let backend = GcsBackend::new(config, &ChangeStreamFactory::default())
682            .await
683            .unwrap();
684        let service = StorageService::new(Box::new(backend), Cipher::ephemeral().unwrap());
685
686        let key = service
687            .insert_object(
688                make_context(),
689                Some("testing".into()),
690                Metadata::default(),
691                stream::single("oh hai!"),
692                Timestamp::now(),
693            )
694            .await
695            .unwrap();
696
697        let (_metadata, _, stream) = service
698            .get_object(key, Timestamp::now(), None)
699            .await
700            .unwrap()
701            .unwrap();
702        let file_contents: BytesMut = stream.try_collect().await.unwrap();
703
704        assert_eq!(file_contents.as_ref(), b"oh hai!");
705    }
706
707    #[tokio::test]
708    async fn tombstone_redirect_and_delete() {
709        let bigtable_config = BigTableConfig {
710            endpoint: Some("localhost:8086".into()),
711            project_id: "testing".into(),
712            instance_name: "objectstore".into(),
713            table_name: "objectstore".into(),
714            connections: None,
715            rpc_timeout: Duration::from_secs(2),
716            cogs: None,
717        };
718        let gcs_config = GcsConfig {
719            endpoint: Some("http://localhost:8087".into()),
720            bucket: "test-bucket".into(),
721            cogs: None,
722        };
723
724        let high_volume = Box::new(
725            BigTableBackend::new(bigtable_config, &ChangeStreamFactory::default())
726                .await
727                .unwrap(),
728        );
729        let long_term = Box::new(
730            GcsBackend::new(gcs_config.clone(), &ChangeStreamFactory::default())
731                .await
732                .unwrap(),
733        );
734        let backend = TieredStorage::new(high_volume, long_term, Box::new(NoopChangeLog));
735        let service = StorageService::new(Box::new(backend), Cipher::ephemeral().unwrap());
736
737        // A separate GCS backend to directly inspect the long-term storage.
738        let gcs_backend = GcsBackend::new(gcs_config.clone(), &ChangeStreamFactory::default())
739            .await
740            .unwrap();
741
742        // Insert a >1 MiB object with a key.  This forces the long-term path:
743        // the real payload goes to GCS, and a redirect tombstone is written to BigTable.
744        let payload_len = 2 * 1024 * 1024;
745        let payload = vec![0xAB; payload_len]; // 2 MiB
746        let id = service
747            .insert_object(
748                make_context(),
749                Some("delete-cleanup-test".into()),
750                Metadata::default(),
751                stream::single(payload),
752                Timestamp::now(),
753            )
754            .await
755            .unwrap();
756
757        // Sanity: the object is readable through the service (follows the tombstone).
758        let (_, _, stream) = service
759            .get_object(id.clone(), Timestamp::now(), None)
760            .await
761            .unwrap()
762            .unwrap();
763        let body: BytesMut = stream.try_collect().await.unwrap();
764        assert_eq!(body.len(), payload_len);
765
766        // Delete through the service layer.
767        service
768            .delete_object(id.clone(), Timestamp::now())
769            .await
770            .unwrap();
771
772        // The tombstone in BigTable should be gone, so the service returns None.
773        let after_delete = service
774            .get_object(id.clone(), Timestamp::now(), None)
775            .await
776            .unwrap();
777        assert!(after_delete.is_none(), "tombstone not deleted");
778
779        // The real object in GCS must also be gone — no orphan.
780        let orphan = gcs_backend
781            .get_object(&id, Timestamp::now(), None)
782            .await
783            .unwrap();
784        assert!(orphan.is_none(), "object leaked");
785    }
786
787    // --- Task spawning tests (public API) ---
788
789    #[tokio::test]
790    async fn basic_spawn_insert_and_get() {
791        let service = make_service();
792
793        let id = service
794            .insert_object(
795                make_context(),
796                Some("test-key".into()),
797                Metadata::default(),
798                stream::single("hello world"),
799                Timestamp::now(),
800            )
801            .await
802            .unwrap();
803
804        let (_, _, stream) = service
805            .get_object(id, Timestamp::now(), None)
806            .await
807            .unwrap()
808            .unwrap();
809        let body: BytesMut = stream.try_collect().await.unwrap();
810        assert_eq!(body.as_ref(), b"hello world");
811    }
812
813    #[tokio::test]
814    async fn basic_spawn_metadata_and_delete() {
815        let service = make_service();
816
817        let id = service
818            .insert_object(
819                make_context(),
820                Some("meta-key".into()),
821                Metadata::default(),
822                stream::single("data"),
823                Timestamp::now(),
824            )
825            .await
826            .unwrap();
827
828        let metadata = service
829            .get_metadata(id.clone(), Timestamp::now())
830            .await
831            .unwrap();
832        assert!(metadata.is_some());
833
834        service
835            .delete_object(id.clone(), Timestamp::now())
836            .await
837            .unwrap();
838
839        let after = service
840            .get_object(id, Timestamp::now(), None)
841            .await
842            .unwrap();
843        assert!(after.is_none());
844    }
845
846    #[tokio::test]
847    async fn set_expiry() {
848        let service = make_service();
849        let old_expiry = Timestamp::now() + Duration::from_hours(1);
850        let metadata = Metadata {
851            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
852            time_expires: Some(old_expiry),
853            ..Default::default()
854        };
855        let id = service
856            .insert_object(
857                make_context(),
858                Some("explicit-expiry".into()),
859                metadata,
860                stream::single("payload"),
861                Timestamp::now(),
862            )
863            .await
864            .unwrap();
865        let requested = old_expiry + Duration::from_hours(1);
866
867        assert!(
868            service
869                .set_expiry(id.clone(), requested, Timestamp::now())
870                .await
871                .unwrap()
872        );
873        assert_eq!(
874            service
875                .get_metadata(id, Timestamp::now())
876                .await
877                .unwrap()
878                .unwrap()
879                .time_expires,
880            Some(requested)
881        );
882    }
883
884    #[derive(Debug)]
885    struct PanicOnGet;
886
887    #[async_trait::async_trait]
888    impl Hooks for PanicOnGet {
889        async fn get_object(
890            &self,
891            _inner: &InMemoryBackend,
892            _id: &ObjectId,
893            _access_time: Timestamp,
894            _range: Option<ByteRange>,
895        ) -> Result<GetResponse> {
896            panic!("intentional panic in get_object");
897        }
898    }
899
900    #[tokio::test]
901    async fn panic_in_backend_returns_task_failed() {
902        let service = StorageService::new(
903            Box::new(TestBackend::new(PanicOnGet)),
904            Cipher::ephemeral().unwrap(),
905        );
906
907        let id = ObjectId::new(make_context(), "panic-test".into());
908        let result = service.get_object(id, Timestamp::now(), None).await;
909
910        let Err(error) = result else {
911            panic!("expected Panic error");
912        };
913        assert_eq!(error.kind(), ErrorKind::Panic);
914        assert_eq!(error.to_string(), "service task panicked");
915        assert_eq!(
916            std::error::Error::source(&error).unwrap().to_string(),
917            "intentional panic in get_object"
918        );
919    }
920
921    #[derive(Clone, Debug, Default)]
922    struct GateOnExpiry {
923        calls: Arc<AtomicUsize>,
924        access_time: Arc<Mutex<Option<Timestamp>>>,
925        started: Arc<tokio::sync::Notify>,
926        resume: Arc<tokio::sync::Notify>,
927    }
928
929    #[async_trait::async_trait]
930    impl Hooks for GateOnExpiry {
931        async fn set_expiry(
932            &self,
933            inner: &InMemoryBackend,
934            id: &ObjectId,
935            expire_at: Timestamp,
936            access_time: Timestamp,
937        ) -> Result<bool> {
938            *self.access_time.lock().unwrap() = Some(access_time);
939            self.calls.fetch_add(1, Ordering::SeqCst);
940            self.started.notify_one();
941            self.resume.notified().await;
942            inner.set_expiry(id, expire_at, access_time).await
943        }
944    }
945
946    fn stale_tti_metadata() -> Metadata {
947        Metadata {
948            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
949            time_expires: Some(Timestamp::now() + Duration::from_mins(1)),
950            ..Default::default()
951        }
952    }
953
954    #[tokio::test]
955    async fn background_renewal() {
956        let now = Timestamp::now();
957        let access_time = now - Duration::from_secs(10);
958        let backend = TestBackend::new(GateOnExpiry::default());
959        let id = ObjectId::new(make_context(), "background-renewal".into());
960        let metadata = stale_tti_metadata();
961        backend
962            .inner
963            .put_object(&id, &metadata, stream::single("payload"), Timestamp::now())
964            .await
965            .unwrap();
966        let mut service =
967            StorageService::new(Box::new(backend.clone()), Cipher::ephemeral().unwrap());
968        service.start();
969
970        let response = tokio::time::timeout(
971            Duration::from_secs(1),
972            service.get_object(id.clone(), access_time, Some(ByteRange::Bounded(0, 2))),
973        )
974        .await
975        .expect("GET waited for its background renewal")
976        .unwrap()
977        .unwrap();
978        assert_eq!(response.0.time_expires, metadata.time_expires);
979        backend.hooks.started.notified().await;
980        assert!(backend.hooks.access_time.lock().unwrap().unwrap() >= now);
981
982        let join = tokio::spawn({
983            let service = service.clone();
984            async move { service.join().await }
985        });
986        tokio::pin!(join);
987        assert!(
988            tokio::time::timeout(Duration::from_millis(25), &mut join)
989                .await
990                .is_err()
991        );
992
993        backend.hooks.resume.notify_waiters();
994        tokio::time::timeout(Duration::from_secs(1), &mut join)
995            .await
996            .expect("shutdown did not drain renewal")
997            .unwrap();
998        assert_eq!(
999            backend.inner.get(&id).expect_object().0.time_expires,
1000            Some(access_time + Duration::from_hours(1))
1001        );
1002    }
1003
1004    #[tokio::test]
1005    async fn renewal_deduplication() {
1006        let backend = TestBackend::new(GateOnExpiry::default());
1007        let id = ObjectId::new(make_context(), "deduplicated-renewal".into());
1008        backend
1009            .inner
1010            .put_object(
1011                &id,
1012                &stale_tti_metadata(),
1013                stream::single("payload"),
1014                Timestamp::now(),
1015            )
1016            .await
1017            .unwrap();
1018        let mut service =
1019            StorageService::new(Box::new(backend.clone()), Cipher::ephemeral().unwrap());
1020        service.start();
1021
1022        service
1023            .get_metadata(id.clone(), Timestamp::now())
1024            .await
1025            .unwrap();
1026        backend.hooks.started.notified().await;
1027        service.get_metadata(id, Timestamp::now()).await.unwrap();
1028        tokio::task::yield_now().await;
1029        assert_eq!(backend.hooks.calls.load(Ordering::SeqCst), 1);
1030
1031        backend.hooks.resume.notify_waiters();
1032        service.join().await;
1033    }
1034
1035    #[tokio::test]
1036    async fn ttl_read() {
1037        let backend = TestBackend::new(GateOnExpiry::default());
1038        let id = ObjectId::new(make_context(), "ttl-no-renewal".into());
1039        let metadata = Metadata {
1040            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1041            time_expires: Some(Timestamp::now() + Duration::from_mins(1)),
1042            ..Default::default()
1043        };
1044        backend
1045            .inner
1046            .put_object(&id, &metadata, stream::single("payload"), Timestamp::now())
1047            .await
1048            .unwrap();
1049        let mut service =
1050            StorageService::new(Box::new(backend.clone()), Cipher::ephemeral().unwrap());
1051        service.start();
1052
1053        service.get_metadata(id, Timestamp::now()).await.unwrap();
1054        tokio::task::yield_now().await;
1055        assert_eq!(backend.hooks.calls.load(Ordering::SeqCst), 0);
1056        service.join().await;
1057    }
1058
1059    #[tokio::test]
1060    async fn renewal_queueing() {
1061        let backend = TestBackend::new(GateOnExpiry::default());
1062        let first = ObjectId::new(make_context(), "first-renewal".into());
1063        let second = ObjectId::new(make_context(), "queued-renewal".into());
1064        let metadata = stale_tti_metadata();
1065        for id in [&first, &second] {
1066            backend
1067                .inner
1068                .put_object(id, &metadata, stream::single("payload"), Timestamp::now())
1069                .await
1070                .unwrap();
1071        }
1072
1073        let concurrency = ConcurrencyLimiter::new(1);
1074        let mut scheduler = RenewalScheduler::new(Arc::new(backend.clone()), concurrency, 1);
1075        let expire_at = Timestamp::now() + Duration::from_hours(1);
1076        scheduler.schedule(first, expire_at);
1077        assert_eq!(scheduler.queued(), 1);
1078        scheduler.start();
1079        backend.hooks.started.notified().await;
1080        assert_eq!(scheduler.queued(), 0);
1081
1082        scheduler.schedule(second.clone(), expire_at);
1083        tokio::task::yield_now().await;
1084        assert_eq!(backend.hooks.calls.load(Ordering::SeqCst), 1);
1085        assert_eq!(
1086            backend.inner.get(&second).expect_object().0.time_expires,
1087            metadata.time_expires
1088        );
1089
1090        backend.hooks.resume.notify_waiters();
1091        tokio::time::timeout(Duration::from_secs(1), async {
1092            while backend.hooks.calls.load(Ordering::SeqCst) < 2 {
1093                tokio::task::yield_now().await;
1094            }
1095        })
1096        .await
1097        .expect("queued renewal did not start");
1098        backend.hooks.resume.notify_waiters();
1099        scheduler.join().await;
1100        assert_eq!(scheduler.queued(), 0);
1101        assert_eq!(backend.hooks.calls.load(Ordering::SeqCst), 2);
1102        assert!(backend.inner.get(&second).expect_object().0.time_expires > metadata.time_expires);
1103    }
1104
1105    #[derive(Clone, Debug, Default)]
1106    struct FailFirstExpiry {
1107        calls: Arc<AtomicUsize>,
1108        panic: bool,
1109    }
1110
1111    #[async_trait::async_trait]
1112    impl Hooks for FailFirstExpiry {
1113        async fn set_expiry(
1114            &self,
1115            inner: &InMemoryBackend,
1116            id: &ObjectId,
1117            expire_at: Timestamp,
1118            access_time: Timestamp,
1119        ) -> Result<bool> {
1120            if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
1121                assert!(!self.panic, "intentional renewal panic");
1122                return Err(ErrorKind::BackendFailure.into());
1123            }
1124            inner.set_expiry(id, expire_at, access_time).await
1125        }
1126    }
1127
1128    #[tokio::test]
1129    async fn renewal_failure_cleanup() {
1130        for panic in [false, true] {
1131            let backend = TestBackend::new(FailFirstExpiry {
1132                panic,
1133                ..Default::default()
1134            });
1135            let id = ObjectId::new(make_context(), "failed-renewal".into());
1136            let metadata = stale_tti_metadata();
1137            backend
1138                .inner
1139                .put_object(&id, &metadata, stream::single("payload"), Timestamp::now())
1140                .await
1141                .unwrap();
1142            let concurrency = ConcurrencyLimiter::new(1);
1143            let mut scheduler = RenewalScheduler::new(Arc::new(backend.clone()), concurrency, 1);
1144            scheduler.start();
1145            let expire_at = metadata.check_tti_bump(Timestamp::now()).unwrap();
1146
1147            scheduler.schedule(id.clone(), expire_at);
1148            tokio::time::timeout(Duration::from_secs(1), async {
1149                while scheduler.pending() != 0 {
1150                    tokio::task::yield_now().await;
1151                }
1152            })
1153            .await
1154            .expect("failure did not release renewal guards");
1155
1156            scheduler.schedule(id, expire_at);
1157            scheduler.join().await;
1158            assert_eq!(backend.hooks.calls.load(Ordering::SeqCst), 2);
1159        }
1160    }
1161
1162    /// In-memory backend with optional synchronization for `put_object`.
1163    ///
1164    /// When `pause` is enabled, each `put_object` call notifies `paused` and
1165    #[derive(Clone, Debug, Default)]
1166    struct GateOnPut {
1167        pause: bool,
1168        paused: Arc<tokio::sync::Notify>,
1169        resume: Arc<tokio::sync::Notify>,
1170        on_put: Arc<tokio::sync::Notify>,
1171    }
1172
1173    impl GateOnPut {
1174        fn with_pause() -> Self {
1175            Self {
1176                pause: true,
1177                ..Default::default()
1178            }
1179        }
1180    }
1181
1182    #[async_trait::async_trait]
1183    impl Hooks for GateOnPut {
1184        async fn put_object(
1185            &self,
1186            inner: &InMemoryBackend,
1187            id: &ObjectId,
1188            metadata: &Metadata,
1189            stream: ClientStream,
1190            access_time: Timestamp,
1191        ) -> Result<PutResponse> {
1192            if self.pause {
1193                self.paused.notify_one();
1194                self.resume.notified().await;
1195            }
1196            inner.put_object(id, metadata, stream, access_time).await?;
1197            self.on_put.notify_one();
1198            Ok(())
1199        }
1200
1201        async fn compare_and_write(
1202            &self,
1203            inner: &InMemoryBackend,
1204            id: &ObjectId,
1205            current: Option<&ObjectId>,
1206            write: TieredWrite,
1207            access_time: Timestamp,
1208        ) -> Result<bool> {
1209            let notify = matches!(write, TieredWrite::Tombstone(_) | TieredWrite::Object(_, _));
1210            let result = inner
1211                .compare_and_write(id, current, write, access_time)
1212                .await?;
1213            if notify {
1214                self.on_put.notify_one();
1215            }
1216            Ok(result)
1217        }
1218    }
1219
1220    #[tokio::test]
1221    async fn receiver_drop_does_not_prevent_completion() {
1222        let hv = Box::new(TestBackend::new(GateOnPut::default()));
1223        let lt = Box::new(TestBackend::new(GateOnPut::with_pause()));
1224        let backend = TieredStorage::new(hv.clone(), lt.clone(), Box::new(NoopChangeLog));
1225        let service = StorageService::new(Box::new(backend), Cipher::ephemeral().unwrap());
1226
1227        let payload = vec![0xABu8; 2 * 1024 * 1024]; // 2 MiB → long-term path
1228        let request = service.insert_object(
1229            make_context(),
1230            Some("completion-test".into()),
1231            Metadata::default(),
1232            stream::single(payload),
1233            Timestamp::now(),
1234        );
1235
1236        // Start insert through the public API. select! drops the future once the
1237        // backend signals it has paused, simulating a client disconnect mid-write.
1238        let paused = Arc::clone(&lt.hooks.paused);
1239        tokio::select! {
1240            _ = request => panic!("insert should not complete while backend is paused"),
1241            _ = paused.notified() => {}
1242        }
1243
1244        // The spawned task is now blocked inside put_object, and the caller
1245        // request (including the oneshot receiver) has been dropped. Unpause so
1246        // the task can finish writing.
1247        lt.hooks.resume.notify_one();
1248
1249        // Wait for the tombstone write to the high-volume backend, which is the
1250        // last step of the long-term insert path.
1251        let on_put = Arc::clone(&hv.hooks.on_put);
1252        tokio::time::timeout(Duration::from_secs(5), on_put.notified())
1253            .await
1254            .expect("timed out waiting for tombstone write");
1255
1256        // Verify the object was fully written despite the caller being dropped.
1257        // The tombstone in HV points to the revision key in LT.
1258        let id = ObjectId::new(make_context(), "completion-test".into());
1259        let tombstone = hv.inner.get(&id).expect_tombstone();
1260        let lt_id = tombstone.target;
1261        assert!(lt.inner.contains(&lt_id), "long-term object missing");
1262    }
1263
1264    // --- Concurrency limit tests ---
1265
1266    fn make_limited_service(limit: u32) -> (StorageService, TestBackend<GateOnPut>) {
1267        let backend = TestBackend::new(GateOnPut::with_pause());
1268        let service = StorageService::new(Box::new(backend.clone()), Cipher::ephemeral().unwrap())
1269            .with_concurrency(ConcurrencyLimiter::new(limit));
1270        (service, backend)
1271    }
1272
1273    #[tokio::test]
1274    async fn at_capacity_rejects() {
1275        let (service, hv) = make_limited_service(1);
1276
1277        // First insert blocks on the gated backend, holding the single permit.
1278        let svc = service.clone();
1279        let first = tokio::spawn(async move {
1280            svc.insert_object(
1281                make_context(),
1282                Some("first".into()),
1283                Metadata::default(),
1284                stream::single("data"),
1285                Timestamp::now(),
1286            )
1287            .await
1288        });
1289
1290        // Wait for the backend to signal it has paused (permit is held).
1291        hv.hooks.paused.notified().await;
1292
1293        // Second insert should be rejected immediately.
1294        let result = service
1295            .insert_object(
1296                make_context(),
1297                Some("second".into()),
1298                Metadata::default(),
1299                stream::single("data"),
1300                Timestamp::now(),
1301            )
1302            .await;
1303
1304        assert!(
1305            result
1306                .as_ref()
1307                .is_err_and(|error| error.kind() == ErrorKind::AtCapacity),
1308            "expected AtCapacity, got {result:?}"
1309        );
1310
1311        // Unblock the first operation.
1312        hv.hooks.resume.notify_one();
1313        first.await.unwrap().unwrap();
1314
1315        // Now that the permit is released, a new operation should succeed.
1316        service
1317            .get_metadata(
1318                ObjectId::new(make_context(), "first".into()),
1319                Timestamp::now(),
1320            )
1321            .await
1322            .unwrap();
1323    }
1324
1325    #[tokio::test]
1326    async fn tasks_limit_returns_configured_limit() {
1327        let backend = Box::new(InMemoryBackend::new("cap"));
1328        let service = StorageService::new(backend, Cipher::ephemeral().unwrap())
1329            .with_concurrency(ConcurrencyLimiter::new(7));
1330        assert_eq!(service.tasks_limit(), 7);
1331    }
1332
1333    #[tokio::test]
1334    async fn tasks_running_tracks_in_flight() {
1335        let (service, hv) = make_limited_service(5);
1336
1337        assert_eq!(service.tasks_running(), 0);
1338
1339        // Kick off a request that blocks in the backend, holding a permit.
1340        let svc = service.clone();
1341        let _blocked = tokio::spawn(async move {
1342            svc.insert_object(
1343                make_context(),
1344                Some("in-use-test".into()),
1345                Metadata::default(),
1346                stream::single("data"),
1347                Timestamp::now(),
1348            )
1349            .await
1350        });
1351
1352        hv.hooks.paused.notified().await;
1353        assert_eq!(service.tasks_running(), 1);
1354
1355        hv.hooks.resume.notify_one();
1356    }
1357
1358    #[tokio::test]
1359    async fn permits_released_after_panic() {
1360        let service = StorageService::new(
1361            Box::new(TestBackend::new(PanicOnGet)),
1362            Cipher::ephemeral().unwrap(),
1363        )
1364        .with_concurrency(ConcurrencyLimiter::new(1));
1365
1366        // First operation panics — the permit must still be released.
1367        let id = ObjectId::new(make_context(), "panic-permit".into());
1368        let result = service.get_object(id.clone(), Timestamp::now(), None).await;
1369        assert!(result.is_err_and(|error| error.kind() == ErrorKind::Panic));
1370
1371        // Second operation should succeed in acquiring the permit (not AtCapacity).
1372        let result = service.get_object(id, Timestamp::now(), None).await;
1373        assert!(
1374            !result.is_err_and(|error| error.kind() == ErrorKind::AtCapacity),
1375            "permit was not released after panic"
1376        );
1377    }
1378
1379    // --- Resumable uploads ---
1380
1381    #[tokio::test]
1382    async fn resumable_create_preserves_backend_refusal_as_none() {
1383        let service = make_service();
1384        let id = ObjectId::new(make_context(), "resumable".into());
1385
1386        let result = service
1387            .create_upload_session(id, Metadata::default(), 1024)
1388            .await;
1389
1390        assert!(matches!(result, Ok(None)), "{result:?}");
1391    }
1392
1393    #[tokio::test]
1394    async fn resumable_create_declines_zero_length() {
1395        let service = StorageService::new(
1396            Box::new(TestBackend::new(ResumableTokenHooks::default())),
1397            Cipher::ephemeral().unwrap(),
1398        );
1399        let id = ObjectId::new(make_context(), "resumable".into());
1400
1401        let result = service
1402            .create_upload_session(id, Metadata::default(), 0)
1403            .await;
1404
1405        assert!(matches!(result, Ok(None)), "{result:?}");
1406    }
1407
1408    #[tokio::test]
1409    async fn resumable_create_validates_metadata() {
1410        let service = make_service();
1411        let id = ObjectId::new(make_context(), "resumable".into());
1412
1413        // A timeout policy with no resolved `time_expires` is rejected before the backend
1414        // is consulted, exactly as it is for a regular insert.
1415        let metadata = Metadata {
1416            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(60)),
1417            ..Default::default()
1418        };
1419
1420        let result = service.create_upload_session(id, metadata, 1024).await;
1421        assert!(result.is_err_and(|error| error.kind() == ErrorKind::InvalidMetadata));
1422    }
1423
1424    #[tokio::test]
1425    async fn resumable_tokens_are_encrypted_by_default() -> Result<()> {
1426        let hooks = ResumableTokenHooks::default();
1427        let service = StorageService::new(
1428            Box::new(TestBackend::new(hooks.clone())),
1429            Cipher::ephemeral().unwrap(),
1430        );
1431        let id = ObjectId::new(make_context(), "resumable".into());
1432
1433        let token = service
1434            .create_upload_session(id.clone(), Metadata::default(), 4)
1435            .await?
1436            .expect("test backend supports resumable uploads");
1437        assert_ne!(token.as_bytes(), b"backend token");
1438        assert!(matches!(
1439            service
1440                .upload_offset(id.clone(), EncryptedSessionToken::new(b"backend token"))
1441                .await,
1442            Err(error) if error.kind() == ErrorKind::UnknownUploadSession
1443        ));
1444        let other_id = ObjectId::new(make_context(), "other".into());
1445        assert!(matches!(
1446            service.upload_offset(other_id, token.clone()).await,
1447            Err(error) if error.kind() == ErrorKind::UnknownUploadSession
1448        ));
1449        assert!(hooks.seen_tokens.lock().unwrap().is_empty());
1450        service.upload_offset(id, token).await?;
1451        assert_eq!(
1452            hooks.seen_tokens.lock().unwrap().as_slice(),
1453            &["backend token"]
1454        );
1455        Ok(())
1456    }
1457
1458    #[tokio::test]
1459    async fn configured_encryption_only_crosses_the_service_boundary() -> Result<()> {
1460        let hooks = ResumableTokenHooks::default();
1461        let encryption = Cipher::new(
1462            "v1",
1463            std::collections::BTreeMap::from([("v1".into(), vec![7; 32])]),
1464        )
1465        .unwrap();
1466        let service = StorageService::new(Box::new(TestBackend::new(hooks.clone())), encryption);
1467        let id = ObjectId::new(make_context(), "resumable".into());
1468
1469        let encrypted = service
1470            .create_upload_session(id.clone(), Metadata::default(), 4)
1471            .await?
1472            .expect("test backend supports resumable uploads");
1473        assert_ne!(encrypted.as_bytes(), b"backend token");
1474        service.upload_offset(id, encrypted).await?;
1475        assert_eq!(
1476            hooks.seen_tokens.lock().unwrap().as_slice(),
1477            &["backend token"]
1478        );
1479        Ok(())
1480    }
1481
1482    #[tokio::test]
1483    async fn configured_encryption_rejects_plaintext_tokens() {
1484        let hooks = ResumableTokenHooks::default();
1485        let encryption = Cipher::new(
1486            "v1",
1487            std::collections::BTreeMap::from([("v1".into(), vec![7; 32])]),
1488        )
1489        .unwrap();
1490        let service = StorageService::new(Box::new(TestBackend::new(hooks.clone())), encryption);
1491        let id = ObjectId::new(make_context(), "resumable".into());
1492
1493        let result = service
1494            .upload_offset(id, EncryptedSessionToken::new(b"backend token"))
1495            .await;
1496        let error = result.unwrap_err();
1497        assert_eq!(error.kind(), ErrorKind::UnknownUploadSession);
1498        assert_eq!(error.to_string(), "unknown upload session");
1499        assert!(error.source().is_none());
1500        assert!(hooks.seen_tokens.lock().unwrap().is_empty());
1501    }
1502}