1use 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
37pub type GetResponse = Option<(Metadata, Option<ContentRange>, PayloadStream)>;
39pub type MetadataResponse = Option<Metadata>;
41pub type InsertResponse = ObjectId;
43pub type DeleteResponse = ();
45
46pub const DEFAULT_CONCURRENCY_LIMIT: u32 = 500;
51
52pub const DEFAULT_BACKGROUND_QUEUE_LIMIT: usize = 1_000;
54
55#[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 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 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 pub fn with_background_queue_limit(mut self, limit: usize) -> Self {
125 self.renewals.set_capacity(limit);
126 self
127 }
128
129 pub fn concurrency_limiter(&self) -> &ConcurrencyLimiter {
131 &self.concurrency
132 }
133
134 pub fn tasks_running(&self) -> u32 {
136 self.concurrency.used_permits()
137 }
138
139 pub fn tasks_limit(&self) -> u32 {
141 self.concurrency.total_permits()
142 }
143
144 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 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 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 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 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 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 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 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 pub async fn join(&self) {
344 self.renewals.join().await;
345 self.inner.join().await;
346 }
347
348 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()?; 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 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()?; 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 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()?; 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 pub async fn abort_multipart(
424 &self,
425 id: ObjectId,
426 upload_id: UploadId,
427 ) -> Result<AbortMultipartResponse> {
428 self.inner.as_multipart_upload_backend()?; 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 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()?; 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 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 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 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 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(), 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 let gcs_backend = GcsBackend::new(gcs_config.clone(), &ChangeStreamFactory::default())
739 .await
740 .unwrap();
741
742 let payload_len = 2 * 1024 * 1024;
745 let payload = vec![0xAB; payload_len]; 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 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 service
768 .delete_object(id.clone(), Timestamp::now())
769 .await
770 .unwrap();
771
772 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 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 #[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 #[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]; 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 let paused = Arc::clone(<.hooks.paused);
1239 tokio::select! {
1240 _ = request => panic!("insert should not complete while backend is paused"),
1241 _ = paused.notified() => {}
1242 }
1243
1244 lt.hooks.resume.notify_one();
1248
1249 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 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(<_id), "long-term object missing");
1262 }
1263
1264 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 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 hv.hooks.paused.notified().await;
1292
1293 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 hv.hooks.resume.notify_one();
1313 first.await.unwrap().unwrap();
1314
1315 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 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 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 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 #[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 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}