1use std::sync::Arc;
115use std::sync::atomic::Ordering;
116use std::time::{Duration, SystemTime};
117
118use base64::Engine as _;
119use bytes::Bytes;
120use futures_util::StreamExt;
121use objectstore_types::metadata::Metadata;
122use objectstore_types::range::ByteRange;
123use objectstore_types::time::Timestamp;
124use sentry::{Hub, SentryFutureExt};
125use serde::{Deserialize, Serialize};
126
127use crate::backend::changelog::{Change, ChangeGuard, ChangeLog, ChangeManager, ChangePhase};
128use crate::backend::common::{
129 Backend, DeleteResponse, GetResponse, HighVolumeBackend, MetadataResponse,
130 MultipartUploadBackend, PutResponse, TieredGet, TieredMetadata, TieredUpdate, TieredWrite,
131 Tombstone,
132};
133use crate::backend::{HighVolumeStorageConfig, MultipartUploadStorageConfig};
134use crate::error::{Error, ErrorKind, Result, ResultExt as _};
135use crate::id::ObjectId;
136use crate::multipart::{
137 AbortMultipartResponse, CompleteMultipartResponse, CompletedPart, InitiateMultipartResponse,
138 ListPartsResponse, PartNumber, UploadId, UploadPartResponse,
139};
140use crate::stream::{ClientStream, SizedPeek, counting_stream};
141
142const BACKEND_SIZE_THRESHOLD: usize = 1024 * 1024; const MULTIPART_COMPLETE_CLEANUP_DELAY: Duration = Duration::from_hours(24);
150
151fn new_long_term_revision(id: &ObjectId) -> ObjectId {
157 ObjectId {
158 context: id.context.clone(),
159 key: format!("{}/{}", id.key, uuid::Uuid::now_v7()),
160 }
161}
162
163#[derive(Debug, Clone, Deserialize, Serialize)]
184pub struct TieredStorageConfig {
185 pub high_volume: HighVolumeStorageConfig,
190 pub long_term: MultipartUploadStorageConfig,
194}
195
196#[derive(Debug)]
246pub struct TieredStorage {
247 inner: Arc<ChangeManager>,
248}
249
250impl TieredStorage {
251 pub fn new(
253 high_volume: Box<dyn HighVolumeBackend>,
254 long_term: Box<dyn MultipartUploadBackend>,
255 changelog: Box<dyn ChangeLog>,
256 ) -> Self {
257 let inner = ChangeManager::new(high_volume, long_term, changelog);
258 let hub = Hub::new_from_top(Hub::current());
259 tokio::spawn(inner.clone().recover().bind_hub(hub));
262 Self { inner }
263 }
264
265 async fn record_change(&self, change: Change) -> Result<ChangeGuard> {
267 self.inner.clone().record(change).await
268 }
269
270 async fn record_assembling(&self, change: Change) -> Result<ChangeGuard> {
273 self.inner.clone().record_assembling(change).await
274 }
275
276 fn backend_type(&self, choice: &BackendChoice) -> &'static str {
278 match choice {
279 BackendChoice::HighVolume => self.inner.high_volume.name(),
280 BackendChoice::LongTerm => self.inner.long_term.name(),
281 }
282 }
283
284 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
289 async fn put_high_volume(
290 &self,
291 id: &ObjectId,
292 metadata: &Metadata,
293 payload: Bytes,
294 access_time: Timestamp,
295 ) -> Result<()> {
296 let tombstone_opt = self
297 .inner
298 .high_volume
299 .put_non_tombstone(id, metadata, payload.clone(), access_time)
300 .await?;
301
302 let Some(Tombstone { target, .. }) = tombstone_opt else {
303 return Ok(());
305 };
306
307 let mut guard = self
309 .record_change(Change {
310 id: id.clone(),
311 new: None,
312 old: Some(target.clone()),
313 cleanup_after: None,
314 })
315 .await?;
316
317 let write = TieredWrite::Object(metadata.clone(), payload);
318 guard.advance(ChangePhase::Written);
319
320 let written = self
321 .inner
322 .high_volume
323 .compare_and_write(id, Some(&target), write, access_time)
324 .await?;
325
326 guard.advance(ChangePhase::compare_and_write(written));
328
329 Ok(())
330 }
331
332 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
337 async fn put_long_term(
338 &self,
339 id: &ObjectId,
340 metadata: &Metadata,
341 stream: ClientStream,
342 access_time: Timestamp,
343 ) -> Result<()> {
344 let current = match self
346 .inner
347 .high_volume
348 .get_tiered_metadata(id, access_time)
349 .await?
350 {
351 TieredMetadata::Tombstone(t) => Some(t.target),
352 _ => None,
353 };
354
355 let new = new_long_term_revision(id);
357 let mut guard = self
358 .record_change(Change {
359 id: id.clone(),
360 new: Some(new.clone()),
361 old: current.clone(),
362 cleanup_after: None,
363 })
364 .await?;
365
366 self.inner
367 .long_term
368 .put_object(&new, metadata, stream, access_time)
369 .await?;
370 guard.advance(ChangePhase::Written);
371
372 let tombstone = Tombstone {
374 target: new.clone(),
375 time_expires: metadata.time_expires,
376 };
377 let written = self
378 .inner
379 .high_volume
380 .compare_and_write(
381 id,
382 current.as_ref(),
383 TieredWrite::Tombstone(tombstone),
384 access_time,
385 )
386 .await?;
387
388 guard.advance(ChangePhase::compare_and_write(written));
390
391 Ok(())
392 }
393}
394
395#[async_trait::async_trait]
396impl Backend for TieredStorage {
397 fn name(&self) -> &'static str {
398 "tiered"
399 }
400
401 fn as_multipart_upload_backend(&self) -> Result<&dyn MultipartUploadBackend> {
402 Ok(self)
403 }
404
405 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
406 async fn put_object(
407 &self,
408 id: &ObjectId,
409 metadata: &Metadata,
410 stream: ClientStream,
411 access_time: Timestamp,
412 ) -> Result<PutResponse> {
413 let timer = objectstore_metrics::timer!("put.latency", usecase = id.usecase().to_owned());
414 if metadata.origin.is_none() {
415 objectstore_metrics::count!("put.origin_missing", usecase = id.usecase().to_owned());
416 }
417
418 let peeked = SizedPeek::new(stream, BACKEND_SIZE_THRESHOLD).await?;
419 objectstore_metrics::record!(
420 "put.first_chunk.latency" = timer.elapsed(),
421 usecase = id.usecase().to_owned(),
422 complete = if peeked.is_exhausted() { "yes" } else { "no" },
423 );
424
425 let (backend_choice, stored_size) = if peeked.is_exhausted() {
426 let payload = peeked.into_bytes().await?;
427 let payload_len = payload.len() as u64;
428 self.put_high_volume(id, metadata, payload, access_time)
429 .await?;
430 (BackendChoice::HighVolume, payload_len)
431 } else {
432 let (stored_size, stream) = counting_stream(peeked.into_stream());
433 self.put_long_term(id, metadata, stream.boxed(), access_time)
434 .await?;
435 (BackendChoice::LongTerm, stored_size.load(Ordering::Acquire))
436 };
437
438 let backend_ty = self.backend_type(&backend_choice);
439 timer
440 .tag("backend_choice", backend_choice.as_str())
441 .tag("backend_type", backend_ty)
442 .record();
443 objectstore_metrics::record!(
444 "put.size" = stored_size,
445 usecase = id.usecase().to_owned(),
446 backend_choice = backend_choice.as_str(),
447 backend_type = backend_ty,
448 upload_type = "direct",
449 );
450
451 Ok(())
452 }
453
454 #[tracing::instrument(level = "debug", skip(self))]
455 async fn get_object(
456 &self,
457 id: &ObjectId,
458 access_time: Timestamp,
459 range: Option<ByteRange>,
460 ) -> Result<GetResponse> {
461 let timer = objectstore_metrics::timer!(
462 "get.latency.pre-response",
463 usecase = id.usecase().to_owned(),
464 );
465
466 let hv_result = self
467 .inner
468 .high_volume
469 .get_tiered_object(id, access_time, range)
470 .await?;
471 let (result, backend_choice) = match hv_result {
472 TieredGet::NotFound => (None, BackendChoice::HighVolume),
473 TieredGet::Object(metadata, content_range, stream) => (
474 Some((metadata, content_range, stream)),
475 BackendChoice::HighVolume,
476 ),
477 TieredGet::Tombstone(tombstone) => (
478 self.inner
479 .long_term
480 .get_object(&tombstone.target, access_time, range)
481 .await?
482 .map(|(meta, range, stream)| (align_expiry(meta, &tombstone), range, stream)),
483 BackendChoice::LongTerm,
484 ),
485 };
486
487 let backend_type = self.backend_type(&backend_choice);
488 timer
489 .tag("backend_choice", backend_choice.as_str())
490 .tag("backend_type", backend_type)
491 .record();
492
493 if let Some((ref metadata, ref content_range, _)) = result {
494 let size = content_range.map(|cr| cr.len() as usize).or(metadata.size);
495 if let Some(size) = size {
496 objectstore_metrics::record!(
497 "get.size" = size,
498 usecase = id.usecase().to_owned(),
499 backend_choice = backend_choice.as_str(),
500 backend_type = backend_type,
501 );
502 }
503 }
504
505 Ok(result)
506 }
507
508 #[tracing::instrument(level = "debug", skip(self))]
509 async fn get_metadata(
510 &self,
511 id: &ObjectId,
512 access_time: Timestamp,
513 ) -> Result<MetadataResponse> {
514 let timer = objectstore_metrics::timer!("head.latency", usecase = id.usecase().to_owned());
515
516 let hv_result = self
517 .inner
518 .high_volume
519 .get_tiered_metadata(id, access_time)
520 .await?;
521 let (result, backend_choice) = match hv_result {
522 TieredMetadata::NotFound => (None, BackendChoice::HighVolume),
523 TieredMetadata::Object(metadata) => (Some(metadata), BackendChoice::HighVolume),
524 TieredMetadata::Tombstone(tombstone) => (
525 self.inner
526 .long_term
527 .get_metadata(&tombstone.target, access_time)
528 .await?
529 .map(|metadata| align_expiry(metadata, &tombstone)),
530 BackendChoice::LongTerm,
531 ),
532 };
533
534 timer
535 .tag("backend_choice", backend_choice.as_str())
536 .tag("backend_type", self.backend_type(&backend_choice))
537 .record();
538
539 Ok(result)
540 }
541
542 async fn set_expiry(
543 &self,
544 id: &ObjectId,
545 expire_at: Timestamp,
546 access_time: Timestamp,
547 ) -> Result<bool> {
548 match self
549 .inner
550 .high_volume
551 .get_tiered_metadata(id, access_time)
552 .await?
553 {
554 TieredMetadata::NotFound => Ok(false),
555 TieredMetadata::Object(_) => {
556 self.inner
557 .high_volume
558 .compare_and_update(id, None, TieredUpdate::SetExpiry(expire_at), access_time)
559 .await
560 }
561 TieredMetadata::Tombstone(tombstone) => {
562 if !self
565 .inner
566 .long_term
567 .set_expiry(&tombstone.target, expire_at, access_time)
568 .await?
569 {
570 return Ok(false);
571 }
572
573 self.inner
577 .high_volume
578 .compare_and_update(
579 id,
580 Some(&tombstone.target),
581 TieredUpdate::SetExpiry(expire_at),
582 access_time,
583 )
584 .await
585 }
586 }
587 }
588
589 #[tracing::instrument(level = "debug", skip(self))]
590 async fn delete_object(&self, id: &ObjectId, access_time: Timestamp) -> Result<DeleteResponse> {
591 let timer =
592 objectstore_metrics::timer!("delete.latency", usecase = id.usecase().to_owned());
593
594 let mut backend_choice = BackendChoice::HighVolume;
595
596 if let Some(tombstone) = self
597 .inner
598 .high_volume
599 .delete_non_tombstone(id, access_time)
600 .await?
601 {
602 backend_choice = BackendChoice::LongTerm;
603
604 let mut guard = self
605 .record_change(Change {
606 id: id.clone(),
607 new: None,
608 old: Some(tombstone.target.clone()),
609 cleanup_after: None,
610 })
611 .await?;
612 guard.advance(ChangePhase::Written);
613
614 let deleted = self
616 .inner
617 .high_volume
618 .compare_and_write(
619 id,
620 Some(&tombstone.target),
621 TieredWrite::Delete,
622 access_time,
623 )
624 .await?;
625
626 guard.advance(ChangePhase::compare_and_write(deleted));
628 }
629
630 timer
631 .tag("backend_choice", backend_choice.as_str())
632 .tag("backend_type", self.backend_type(&backend_choice))
633 .record();
634
635 Ok(())
636 }
637
638 async fn join(&self) {
639 self.inner.tracker.close();
640 tokio::join!(
641 self.inner.high_volume.join(),
642 self.inner.long_term.join(),
643 self.inner.tracker.wait()
644 );
645 }
646}
647
648#[derive(Debug)]
649enum BackendChoice {
650 HighVolume,
651 LongTerm,
652}
653
654impl BackendChoice {
655 fn as_str(&self) -> &'static str {
656 match self {
657 BackendChoice::HighVolume => "high-volume",
658 BackendChoice::LongTerm => "long-term",
659 }
660 }
661}
662
663impl std::fmt::Display for BackendChoice {
664 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
665 f.write_str(self.as_str())
666 }
667}
668
669fn effective_expiry(
673 redirect_expiry: Option<Timestamp>,
674 blob_expiry: Option<Timestamp>,
675) -> Option<Timestamp> {
676 match (redirect_expiry, blob_expiry) {
677 (Some(redirect), Some(blob)) => Some(redirect.min(blob)),
678 (Some(expiry), None) | (None, Some(expiry)) => Some(expiry),
679 (None, None) => None,
680 }
681}
682
683fn align_expiry(mut metadata: Metadata, tombstone: &Tombstone) -> Metadata {
688 metadata.time_expires = effective_expiry(tombstone.time_expires, metadata.time_expires);
689 metadata
690}
691
692#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
694struct TieredUploadId {
695 revision: String,
696 upload_id: UploadId,
697}
698
699impl TryInto<UploadId> for TieredUploadId {
700 type Error = Error;
701
702 fn try_into(self) -> Result<UploadId, Self::Error> {
703 let json =
704 serde_json::to_vec(&self).context(ErrorKind::Internal, "encoding tiered upload ID")?;
705 Ok(UploadId::new(
706 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json),
707 )?)
708 }
709}
710
711impl TryFrom<&UploadId> for TieredUploadId {
712 type Error = Error;
713
714 fn try_from(value: &UploadId) -> Result<Self, Self::Error> {
715 let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
716 .decode(value.as_bytes())
717 .kind(ErrorKind::InvalidUploadId)?;
718 serde_json::from_slice(&json).kind(ErrorKind::InvalidUploadId)
719 }
720}
721
722#[async_trait::async_trait]
723impl MultipartUploadBackend for TieredStorage {
724 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
725 async fn initiate_multipart(
726 &self,
727 id: &ObjectId,
728 metadata: &Metadata,
729 ) -> Result<InitiateMultipartResponse> {
730 let timer = objectstore_metrics::timer!(
731 "multipart.initiate.latency",
732 usecase = id.usecase().to_owned(),
733 );
734 let physical = new_long_term_revision(id);
735
736 let upload_id = self
737 .inner
738 .long_term
739 .initiate_multipart(&physical, metadata)
740 .await?;
741
742 let id = TieredUploadId {
743 revision: physical.key,
744 upload_id,
745 };
746 let id = id.try_into()?;
747
748 timer.record();
749 Ok(id)
750 }
751
752 #[tracing::instrument(level = "debug", fields(?id, part_number, content_length), skip_all)]
753 async fn upload_part(
754 &self,
755 id: &ObjectId,
756 upload_id: &UploadId,
757 part_number: PartNumber,
758 content_length: u64,
759 content_md5: Option<&str>,
760 body: ClientStream,
761 ) -> Result<UploadPartResponse> {
762 let timer = objectstore_metrics::timer!(
763 "multipart.upload_part.latency",
764 usecase = id.usecase().to_owned(),
765 );
766 let tiered: TieredUploadId = upload_id.try_into()?;
767
768 let physical = ObjectId {
769 context: id.context.clone(),
770 key: tiered.revision,
771 };
772
773 let etag = self
774 .inner
775 .long_term
776 .upload_part(
777 &physical,
778 &tiered.upload_id,
779 part_number,
780 content_length,
781 content_md5,
782 body,
783 )
784 .await?;
785
786 timer.record();
787 objectstore_metrics::record!(
788 "multipart.upload_part.size" = content_length,
789 usecase = id.usecase().to_owned(),
790 );
791
792 Ok(etag)
793 }
794
795 #[tracing::instrument(level = "debug", skip(self, upload_id))]
796 async fn list_parts(
797 &self,
798 id: &ObjectId,
799 upload_id: &UploadId,
800 max_parts: Option<u32>,
801 part_number_marker: Option<PartNumber>,
802 ) -> Result<ListPartsResponse> {
803 let timer = objectstore_metrics::timer!(
804 "multipart.list_parts.latency",
805 usecase = id.usecase().to_owned(),
806 );
807 let tiered: TieredUploadId = upload_id.try_into()?;
808
809 let physical = ObjectId {
810 context: id.context.clone(),
811 key: tiered.revision,
812 };
813
814 let response = self
815 .inner
816 .long_term
817 .list_parts(&physical, &tiered.upload_id, max_parts, part_number_marker)
818 .await?;
819
820 timer.record();
821 Ok(response)
822 }
823
824 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
825 async fn abort_multipart(
826 &self,
827 id: &ObjectId,
828 upload_id: &UploadId,
829 ) -> Result<AbortMultipartResponse> {
830 let timer = objectstore_metrics::timer!(
831 "multipart.abort.latency",
832 usecase = id.usecase().to_owned(),
833 );
834 let tiered: TieredUploadId = upload_id.try_into()?;
835
836 let physical = ObjectId {
837 context: id.context.clone(),
838 key: tiered.revision,
839 };
840
841 let () = self
842 .inner
843 .long_term
844 .abort_multipart(&physical, &tiered.upload_id)
845 .await?;
846
847 timer.record();
848 Ok(())
849 }
850
851 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
852 async fn complete_multipart(
853 &self,
854 id: &ObjectId,
855 upload_id: &UploadId,
856 parts: Vec<CompletedPart>,
857 access_time: Timestamp,
858 ) -> Result<CompleteMultipartResponse> {
859 let timer = objectstore_metrics::timer!(
860 "multipart.complete.latency",
861 usecase = id.usecase().to_owned(),
862 );
863 let part_count = parts.len();
864 let tiered: TieredUploadId = upload_id.try_into()?;
865
866 let physical = ObjectId {
867 context: id.context.clone(),
868 key: tiered.revision,
869 };
870
871 let current = match self
873 .inner
874 .high_volume
875 .get_tiered_metadata(id, access_time)
876 .await?
877 {
878 TieredMetadata::Tombstone(t) if t.target == physical => {
880 timer.record();
881 return Ok(None);
882 }
883 TieredMetadata::Tombstone(t) => Some(t.target),
884 _ => None,
885 };
886
887 let mut guard = self
890 .record_assembling(Change {
891 id: id.clone(),
892 new: Some(physical.clone()),
893 old: current.clone(),
894 cleanup_after: Some(SystemTime::now() + MULTIPART_COMPLETE_CLEANUP_DELAY),
895 })
896 .await?;
897
898 let maybe_complete_multipart_err = match self
900 .inner
901 .long_term
902 .complete_multipart(&physical, &tiered.upload_id, parts, access_time)
903 .await
904 {
905 Ok(error) => {
908 if error.is_some() {
909 return Ok(error);
910 }
911 None
912 }
913 Err(err) => Some(err),
919 };
920
921 let metadata = self
928 .inner
929 .long_term
930 .get_metadata(&physical, access_time)
931 .await;
932
933 let metadata = match (metadata, maybe_complete_multipart_err) {
934 (Ok(Some(metadata)), _) => metadata,
936 (Ok(None), Some(err)) => return Err(err),
938 (Ok(None), None) => {
941 objectstore_log::error!(
942 id = ?id,
943 upload_id = ?upload_id,
944 physical = ?physical,
945 "complete_multipart call succeeded on long_term backend, but subsequent get_metadata found no object"
946 );
947 return Err(Error::new(
948 ErrorKind::BackendFailure,
949 "tiered multipart object missing from long-term storage",
950 ));
951 }
952 (Err(get_metadata_err), maybe_complete_multipart_err) => {
954 return Err(maybe_complete_multipart_err.unwrap_or(get_metadata_err));
958 }
959 };
960
961 let tombstone = Tombstone {
963 target: physical.clone(),
964 time_expires: metadata.time_expires,
965 };
966 let written = self
967 .inner
968 .high_volume
969 .compare_and_write(
970 id,
971 current.as_ref(),
972 TieredWrite::Tombstone(tombstone),
973 access_time,
974 )
975 .await?;
976
977 guard.advance(ChangePhase::compare_and_write(written));
979
980 timer.record();
981 objectstore_metrics::record!(
982 "multipart.complete.part_count" = part_count as u64,
983 usecase = id.usecase().to_owned(),
984 );
985 if let Some(size) = metadata.size {
986 objectstore_metrics::record!(
987 "put.size" = size as u64,
988 usecase = id.usecase().to_owned(),
989 backend_choice = BackendChoice::LongTerm.as_str(),
990 backend_type = self.backend_type(&BackendChoice::LongTerm),
991 upload_type = "multipart",
992 );
993 }
994
995 Ok(None)
996 }
997}
998
999#[cfg(test)]
1000mod tests {
1001 use std::num::NonZeroU32;
1002 use std::sync::Mutex as StdMutex;
1003
1004 use futures::lock::Mutex;
1005 use objectstore_types::metadata::{ExpirationPolicy, Metadata};
1006 use objectstore_types::scope::{Scope, Scopes};
1007
1008 use super::*;
1009 use crate::backend::changelog::{InMemoryChangeLog, NoopChangeLog};
1010 use crate::backend::in_memory::InMemoryBackend;
1011 use crate::backend::testing::{Hooks, TestBackend};
1012 use crate::error::Error;
1013 use crate::id::ObjectContext;
1014
1015 use crate::stream::{self, ClientStream};
1016
1017 fn make_context() -> ObjectContext {
1018 ObjectContext {
1019 usecase: "testing".into(),
1020 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1021 }
1022 }
1023
1024 fn make_id(key: &str) -> ObjectId {
1025 ObjectId::new(make_context(), key.into())
1026 }
1027
1028 fn make_tiered_storage() -> (
1029 TieredStorage,
1030 InMemoryBackend,
1031 InMemoryBackend,
1032 InMemoryChangeLog,
1033 ) {
1034 let hv = InMemoryBackend::new("in-memory-hv");
1035 let lt = InMemoryBackend::new("in-memory-lt");
1036 let changelog = InMemoryChangeLog::default();
1037 let storage = TieredStorage::new(
1038 Box::new(hv.clone()),
1039 Box::new(lt.clone()),
1040 Box::new(changelog.clone()),
1041 );
1042 (storage, hv, lt, changelog)
1043 }
1044
1045 #[derive(Clone, Debug)]
1046 struct ExpiryHook {
1047 label: &'static str,
1048 events: Arc<StdMutex<Vec<&'static str>>>,
1049 reject: bool,
1050 }
1051
1052 type ExpiryTestStorage = (
1053 TieredStorage,
1054 TestBackend<ExpiryHook>,
1055 TestBackend<ExpiryHook>,
1056 Arc<StdMutex<Vec<&'static str>>>,
1057 );
1058
1059 #[async_trait::async_trait]
1060 impl Hooks for ExpiryHook {
1061 async fn set_expiry(
1062 &self,
1063 inner: &InMemoryBackend,
1064 id: &ObjectId,
1065 expire_at: Timestamp,
1066 access_time: Timestamp,
1067 ) -> Result<bool> {
1068 self.events.lock().unwrap().push(self.label);
1069 if self.reject {
1070 Ok(false)
1071 } else {
1072 inner.set_expiry(id, expire_at, access_time).await
1073 }
1074 }
1075
1076 async fn compare_and_update(
1077 &self,
1078 inner: &InMemoryBackend,
1079 id: &ObjectId,
1080 current: Option<&ObjectId>,
1081 update: TieredUpdate,
1082 access_time: Timestamp,
1083 ) -> Result<bool> {
1084 self.events.lock().unwrap().push(self.label);
1085 if self.reject {
1086 Ok(false)
1087 } else {
1088 inner
1089 .compare_and_update(id, current, update, access_time)
1090 .await
1091 }
1092 }
1093 }
1094
1095 fn tiered_with_expiry_hooks(hv_reject: bool, lt_reject: bool) -> ExpiryTestStorage {
1096 let events = Arc::new(StdMutex::new(Vec::new()));
1097 let hv = TestBackend::new(ExpiryHook {
1098 label: "hv",
1099 events: Arc::clone(&events),
1100 reject: hv_reject,
1101 });
1102 let lt = TestBackend::new(ExpiryHook {
1103 label: "lt",
1104 events: Arc::clone(&events),
1105 reject: lt_reject,
1106 });
1107 let storage = TieredStorage::new(
1108 Box::new(hv.clone()),
1109 Box::new(lt.clone()),
1110 Box::new(NoopChangeLog),
1111 );
1112 (storage, hv, lt, events)
1113 }
1114
1115 async fn seed_redirect(
1116 hv: &InMemoryBackend,
1117 lt: &InMemoryBackend,
1118 id: &ObjectId,
1119 target: &ObjectId,
1120 expiry: Timestamp,
1121 ) {
1122 let metadata = Metadata {
1123 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1124 time_expires: Some(expiry),
1125 ..Default::default()
1126 };
1127 lt.put_object(
1128 target,
1129 &metadata,
1130 stream::single("payload"),
1131 Timestamp::now(),
1132 )
1133 .await
1134 .unwrap();
1135 hv.compare_and_write(
1136 id,
1137 None,
1138 TieredWrite::Tombstone(Tombstone {
1139 target: target.clone(),
1140 time_expires: metadata.time_expires,
1141 }),
1142 Timestamp::now(),
1143 )
1144 .await
1145 .unwrap();
1146 }
1147
1148 #[tokio::test]
1149 async fn set_expiry() {
1150 let (storage, hv, lt, events) = tiered_with_expiry_hooks(false, false);
1151 let id = make_id("tiered-expiry-order");
1152 let target = new_long_term_revision(&id);
1153 let old_expiry = Timestamp::now() + Duration::from_mins(10);
1154 seed_redirect(&hv.inner, <.inner, &id, &target, old_expiry).await;
1155
1156 let requested = Timestamp::now() + Duration::from_hours(1);
1157 assert!(
1158 storage
1159 .set_expiry(&id, requested, Timestamp::now())
1160 .await
1161 .unwrap()
1162 );
1163 assert_eq!(events.lock().unwrap().as_slice(), &["lt", "hv"]);
1164 assert_eq!(
1165 lt.inner.get(&target).expect_object().0.time_expires,
1166 Some(requested)
1167 );
1168 assert_eq!(
1169 hv.inner.get(&id).expect_tombstone().time_expires,
1170 Some(requested)
1171 );
1172 }
1173
1174 #[tokio::test]
1175 async fn expiry_hv_conflict() {
1176 let (storage, hv, lt, events) = tiered_with_expiry_hooks(true, false);
1177 let id = make_id("tiered-hv-failure");
1178 let target = new_long_term_revision(&id);
1179 let old_expiry = Timestamp::now() + Duration::from_mins(10);
1180 seed_redirect(&hv.inner, <.inner, &id, &target, old_expiry).await;
1181
1182 let requested = Timestamp::now() + Duration::from_hours(1);
1183 assert!(
1184 !storage
1185 .set_expiry(&id, requested, Timestamp::now())
1186 .await
1187 .unwrap()
1188 );
1189 assert_eq!(events.lock().unwrap().as_slice(), &["lt", "hv"]);
1190 assert_eq!(
1191 hv.inner.get(&id).expect_tombstone().time_expires,
1192 Some(old_expiry)
1193 );
1194 assert!(lt.inner.get(&target).expect_object().0.time_expires > Some(old_expiry));
1195 assert_eq!(
1196 storage
1197 .get_metadata(&id, Timestamp::now())
1198 .await
1199 .unwrap()
1200 .unwrap()
1201 .time_expires,
1202 Some(old_expiry)
1203 );
1204 }
1205
1206 #[tokio::test]
1207 async fn expiry_lt_conflict() {
1208 let (storage, hv, lt, events) = tiered_with_expiry_hooks(false, true);
1209 let id = make_id("tiered-lt-conflict");
1210 let target = new_long_term_revision(&id);
1211 seed_redirect(
1212 &hv.inner,
1213 <.inner,
1214 &id,
1215 &target,
1216 Timestamp::now() + Duration::from_mins(10),
1217 )
1218 .await;
1219
1220 assert!(
1221 !storage
1222 .set_expiry(
1223 &id,
1224 Timestamp::now() + Duration::from_hours(1),
1225 Timestamp::now()
1226 )
1227 .await
1228 .unwrap()
1229 );
1230 assert_eq!(events.lock().unwrap().as_slice(), &["lt"]);
1231 }
1232
1233 #[test]
1236 fn revision_id_preserves_context() {
1237 let id = make_id("my-key");
1238 let revised = new_long_term_revision(&id);
1239 assert_eq!(revised.context, id.context);
1240 assert!(
1241 revised.key.starts_with("my-key/"),
1242 "revised key should have /<uuid> suffix, got: {}",
1243 revised.key
1244 );
1245 }
1246
1247 #[test]
1248 fn revision_id_roundtrips_storage_path() {
1249 let id = make_id("original");
1250 let revised = new_long_term_revision(&id);
1251 let path = revised.as_storage_path().to_string();
1252 let parsed = ObjectId::from_storage_path(&path)
1253 .unwrap_or_else(|| panic!("failed to parse '{path}'"));
1254 assert_eq!(parsed, revised);
1255 }
1256
1257 #[test]
1258 fn revision_id_is_unique() {
1259 let id = make_id("base-key");
1260 let a = new_long_term_revision(&id);
1261 let b = new_long_term_revision(&id);
1262 assert_ne!(a.key, b.key, "two calls should produce different keys");
1263 }
1264
1265 #[tokio::test]
1268 async fn get_nonexistent_returns_none() {
1269 let (storage, _hv, _lt, _) = make_tiered_storage();
1270 let id = make_id("does-not-exist");
1271
1272 assert!(
1273 storage
1274 .get_object(&id, Timestamp::now(), None)
1275 .await
1276 .unwrap()
1277 .is_none()
1278 );
1279 assert!(
1280 storage
1281 .get_metadata(&id, Timestamp::now())
1282 .await
1283 .unwrap()
1284 .is_none()
1285 );
1286 }
1287
1288 #[tokio::test]
1289 async fn delete_nonexistent_succeeds() {
1290 let (storage, _hv, _lt, _) = make_tiered_storage();
1291 let id = make_id("does-not-exist");
1292
1293 storage.delete_object(&id, Timestamp::now()).await.unwrap();
1294 }
1295
1296 #[tokio::test]
1299 async fn put_small_object_stores_inline() {
1300 let (storage, hv, lt, _) = make_tiered_storage();
1301 let id = make_id("small");
1302 let payload = b"small payload".to_vec();
1303
1304 storage
1305 .put_object(
1306 &id,
1307 &Metadata::default(),
1308 stream::single(payload.clone()),
1309 Timestamp::now(),
1310 )
1311 .await
1312 .unwrap();
1313
1314 assert!(hv.contains(&id), "expected in high-volume");
1315 assert!(!lt.contains(&id), "leaked to long-term");
1316
1317 let (_, _, s) = storage
1318 .get_object(&id, Timestamp::now(), None)
1319 .await
1320 .unwrap()
1321 .unwrap();
1322 let body = stream::read_to_vec(s).await.unwrap();
1323 assert_eq!(body, payload);
1324
1325 assert!(
1326 storage
1327 .get_metadata(&id, Timestamp::now())
1328 .await
1329 .unwrap()
1330 .is_some(),
1331 "get_metadata should return metadata for inline objects"
1332 );
1333 }
1334
1335 #[tokio::test]
1336 async fn put_large_object_creates_tombstone() {
1337 let (storage, hv, lt, _) = make_tiered_storage();
1338 let id = make_id("large");
1339 let payload = vec![0xCDu8; 2 * 1024 * 1024]; let metadata_in = Metadata {
1341 content_type: "image/png".into(),
1342 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1343 time_expires: Some(Timestamp::now() + Duration::from_hours(1)),
1344 origin: Some("10.0.0.1".into()),
1345 ..Metadata::default()
1346 };
1347
1348 storage
1349 .put_object(
1350 &id,
1351 &metadata_in,
1352 stream::single(payload.clone()),
1353 Timestamp::now(),
1354 )
1355 .await
1356 .unwrap();
1357
1358 let tombstone = hv.get(&id).expect_tombstone();
1360 assert_eq!(tombstone.time_expires, metadata_in.time_expires);
1361 let lt_id = tombstone.target;
1362 assert!(
1363 lt_id.key().starts_with(id.key()),
1364 "tombstone target key should be a revision of the HV key, got: {}",
1365 lt_id.key()
1366 );
1367
1368 let (lt_meta, _) = lt.get(<_id).expect_object();
1370 assert_eq!(lt_meta.content_type, "image/png");
1371 assert_eq!(lt_meta.expiration_policy, metadata_in.expiration_policy);
1372 assert_eq!(lt_meta.time_expires, tombstone.time_expires);
1373
1374 let (_, _, s) = storage
1376 .get_object(&id, Timestamp::now(), None)
1377 .await
1378 .unwrap()
1379 .unwrap();
1380 let body = stream::read_to_vec(s).await.unwrap();
1381 assert_eq!(body, payload);
1382
1383 let metadata = storage
1385 .get_metadata(&id, Timestamp::now())
1386 .await
1387 .unwrap()
1388 .unwrap();
1389 assert_eq!(metadata.content_type, "image/png");
1390 }
1391
1392 #[tokio::test]
1395 async fn reinsert_small_over_large_swaps_to_inline() {
1396 let (storage, hv, lt, _) = make_tiered_storage();
1397 let id = make_id("reinsert-key");
1398
1399 let large_payload = vec![0xABu8; 2 * 1024 * 1024];
1401 storage
1402 .put_object(
1403 &id,
1404 &Metadata::default(),
1405 stream::single(large_payload),
1406 Timestamp::now(),
1407 )
1408 .await
1409 .unwrap();
1410
1411 let lt_id = hv.get(&id).expect_tombstone().target;
1412
1413 let small_payload = vec![0xCDu8; 100]; storage
1417 .put_object(
1418 &id,
1419 &Metadata::default(),
1420 stream::single(small_payload),
1421 Timestamp::now(),
1422 )
1423 .await
1424 .unwrap();
1425
1426 hv.get(&id).expect_object();
1428
1429 storage.join().await;
1431
1432 lt.get(<_id).expect_not_found();
1434 }
1435
1436 #[tokio::test]
1437 async fn overwrite_large_with_large_replaces_revision() {
1438 let (storage, hv, lt, _) = make_tiered_storage();
1439 let id = make_id("overwrite-large");
1440
1441 let payload1 = vec![0xAAu8; 2 * 1024 * 1024];
1442 storage
1443 .put_object(
1444 &id,
1445 &Metadata::default(),
1446 stream::single(payload1),
1447 Timestamp::now(),
1448 )
1449 .await
1450 .unwrap();
1451 let lt_id_1 = hv.get(&id).expect_tombstone().target;
1452
1453 let payload2 = vec![0xBBu8; 2 * 1024 * 1024];
1454 storage
1455 .put_object(
1456 &id,
1457 &Metadata::default(),
1458 stream::single(payload2.clone()),
1459 Timestamp::now(),
1460 )
1461 .await
1462 .unwrap();
1463 let lt_id_2 = hv.get(&id).expect_tombstone().target;
1464
1465 assert_ne!(
1466 lt_id_1, lt_id_2,
1467 "second write should create a new revision"
1468 );
1469
1470 storage.join().await;
1472
1473 lt.get(<_id_1).expect_not_found();
1474 lt.get(<_id_2).expect_object();
1475
1476 let (_, _, s) = storage
1477 .get_object(&id, Timestamp::now(), None)
1478 .await
1479 .unwrap()
1480 .unwrap();
1481 let body = stream::read_to_vec(s).await.unwrap();
1482 assert_eq!(body, payload2);
1483 }
1484
1485 #[tokio::test]
1488 async fn delete_small_object() {
1489 let (storage, hv, _lt, _) = make_tiered_storage();
1490 let id = make_id("delete-small");
1491
1492 storage
1493 .put_object(
1494 &id,
1495 &Metadata::default(),
1496 stream::single("tiny"),
1497 Timestamp::now(),
1498 )
1499 .await
1500 .unwrap();
1501
1502 storage.delete_object(&id, Timestamp::now()).await.unwrap();
1503
1504 hv.get(&id).expect_not_found();
1505 assert!(
1506 storage
1507 .get_object(&id, Timestamp::now(), None)
1508 .await
1509 .unwrap()
1510 .is_none()
1511 );
1512 }
1513
1514 #[tokio::test]
1515 async fn delete_large_object_cleans_up_both_backends() {
1516 let (storage, hv, lt, _) = make_tiered_storage();
1517 let id = make_id("delete-both");
1518 let payload = vec![0u8; 2 * 1024 * 1024]; storage
1521 .put_object(
1522 &id,
1523 &Metadata::default(),
1524 stream::single(payload),
1525 Timestamp::now(),
1526 )
1527 .await
1528 .unwrap();
1529
1530 let lt_id = hv.get(&id).expect_tombstone().target;
1532
1533 storage.delete_object(&id, Timestamp::now()).await.unwrap();
1534
1535 storage.join().await;
1537
1538 assert!(!hv.contains(&id), "tombstone not cleaned up");
1539 assert!(!lt.contains(<_id), "long-term object not cleaned up");
1540 }
1541
1542 #[derive(Debug)]
1543 struct FailDelete;
1544
1545 #[async_trait::async_trait]
1546 impl Hooks for FailDelete {
1547 async fn delete_object(
1548 &self,
1549 _inner: &InMemoryBackend,
1550 _id: &ObjectId,
1551 _access_time: Timestamp,
1552 ) -> Result<DeleteResponse> {
1553 Err(Error::with_source(
1554 ErrorKind::BackendFailure,
1555 std::io::Error::new(
1556 std::io::ErrorKind::ConnectionRefused,
1557 "simulated long-term delete failure",
1558 ),
1559 ))
1560 }
1561 }
1562
1563 #[tokio::test]
1567 async fn delete_succeeds_when_gcs_cleanup_fails() {
1568 let hv = InMemoryBackend::new("hv");
1569 let lt = TestBackend::new(FailDelete);
1570 let log = NoopChangeLog;
1571 let storage = TieredStorage::new(Box::new(hv.clone()), Box::new(lt), Box::new(log));
1572
1573 let id = make_id("fail-delete");
1574 let payload = vec![0xABu8; 2 * 1024 * 1024]; storage
1576 .put_object(
1577 &id,
1578 &Metadata::default(),
1579 stream::single(payload),
1580 Timestamp::now(),
1581 )
1582 .await
1583 .unwrap();
1584
1585 let result = storage.delete_object(&id, Timestamp::now()).await;
1587 assert!(
1588 result.is_ok(),
1589 "delete should succeed despite GCS cleanup failure"
1590 );
1591
1592 hv.get(&id).expect_not_found();
1594
1595 assert!(
1597 storage
1598 .get_object(&id, Timestamp::now(), None)
1599 .await
1600 .unwrap()
1601 .is_none(),
1602 "object should be unreachable after tombstone is deleted"
1603 );
1604 }
1605
1606 #[derive(Debug)]
1609 struct CasConflict;
1610
1611 #[async_trait::async_trait]
1612 impl Hooks for CasConflict {
1613 async fn compare_and_write(
1614 &self,
1615 _inner: &InMemoryBackend,
1616 _id: &ObjectId,
1617 _current: Option<&ObjectId>,
1618 _write: TieredWrite,
1619 _access_time: Timestamp,
1620 ) -> Result<bool> {
1621 Ok(false) }
1623 }
1624
1625 #[tokio::test]
1629 async fn put_large_cas_conflict_cleans_up_new_blob() {
1630 let hv = TestBackend::new(CasConflict);
1631 let lt = InMemoryBackend::new("lt");
1632 let log = NoopChangeLog;
1633 let storage = TieredStorage::new(Box::new(hv), Box::new(lt.clone()), Box::new(log));
1634
1635 let id = make_id("cas-conflict-large");
1636 let payload = vec![0xABu8; 2 * 1024 * 1024]; storage
1639 .put_object(
1640 &id,
1641 &Metadata::default(),
1642 stream::single(payload),
1643 Timestamp::now(),
1644 )
1645 .await
1646 .unwrap();
1647
1648 storage.join().await;
1650
1651 assert!(
1652 lt.is_empty(),
1653 "LT blob should be cleaned up after CAS conflict"
1654 );
1655 }
1656
1657 #[tokio::test]
1661 async fn put_small_over_tombstone_cas_conflict_succeeds() {
1662 let inner = InMemoryBackend::new("hv");
1663 let id = make_id("cas-conflict-small");
1664
1665 let tombstone = Tombstone {
1668 target: make_id("lt-object"),
1669 time_expires: None,
1670 };
1671 inner
1672 .compare_and_write(
1673 &id,
1674 None,
1675 TieredWrite::Tombstone(tombstone),
1676 Timestamp::now(),
1677 )
1678 .await
1679 .unwrap();
1680
1681 let lt = InMemoryBackend::new("lt");
1682 let hv = TestBackend::with_inner(inner, CasConflict);
1683 let log = NoopChangeLog;
1684 let storage = TieredStorage::new(Box::new(hv), Box::new(lt), Box::new(log));
1685
1686 storage
1689 .put_object(
1690 &id,
1691 &Metadata::default(),
1692 stream::single("tiny"),
1693 Timestamp::now(),
1694 )
1695 .await
1696 .unwrap();
1697 }
1698
1699 #[derive(Debug)]
1703 struct FailCas(bool);
1704
1705 #[async_trait::async_trait]
1706 impl Hooks for FailCas {
1707 async fn compare_and_write(
1708 &self,
1709 inner: &InMemoryBackend,
1710 id: &ObjectId,
1711 current: Option<&ObjectId>,
1712 write: TieredWrite,
1713 access_time: Timestamp,
1714 ) -> Result<bool> {
1715 if self.0 {
1716 inner
1718 .compare_and_write(id, current, write, access_time)
1719 .await?;
1720 }
1721 Err(Error::with_source(
1722 ErrorKind::BackendFailure,
1723 std::io::Error::new(
1724 std::io::ErrorKind::TimedOut,
1725 "simulated compare_and_write failure",
1726 ),
1727 ))
1728 }
1729 }
1730
1731 #[tokio::test]
1735 async fn no_orphan_when_tombstone_write_fails() {
1736 let lt = InMemoryBackend::new("lt");
1737 let hv = TestBackend::new(FailCas(false));
1738 let log = NoopChangeLog;
1739 let storage = TieredStorage::new(Box::new(hv), Box::new(lt.clone()), Box::new(log));
1740
1741 let id = make_id("orphan-test");
1742 let payload = vec![0xABu8; 2 * 1024 * 1024]; let result = storage
1744 .put_object(
1745 &id,
1746 &Metadata::default(),
1747 stream::single(payload),
1748 Timestamp::now(),
1749 )
1750 .await;
1751
1752 assert!(result.is_err());
1753
1754 storage.join().await;
1756
1757 assert!(lt.is_empty(), "long-term object not cleaned up");
1758 }
1759
1760 #[tokio::test]
1764 async fn orphan_tombstone_returns_none() {
1765 let (storage, hv, lt, _) = make_tiered_storage();
1766 let id = make_id("orphan-tombstone");
1767 let payload = vec![0xCDu8; 2 * 1024 * 1024]; storage
1770 .put_object(
1771 &id,
1772 &Metadata::default(),
1773 stream::single(payload),
1774 Timestamp::now(),
1775 )
1776 .await
1777 .unwrap();
1778
1779 let lt_id = hv.get(&id).expect_tombstone().target;
1781
1782 lt.remove(<_id);
1784
1785 assert!(
1786 storage
1787 .get_object(&id, Timestamp::now(), None)
1788 .await
1789 .unwrap()
1790 .is_none(),
1791 "orphan tombstone should resolve to None on get_object"
1792 );
1793 assert!(
1794 storage
1795 .get_metadata(&id, Timestamp::now())
1796 .await
1797 .unwrap()
1798 .is_none(),
1799 "orphan tombstone should resolve to None on get_metadata"
1800 );
1801 }
1802
1803 #[tokio::test]
1808 async fn tombstone_target_is_used_for_reads_and_deletes() {
1809 let hv = InMemoryBackend::new("hv");
1810 let lt = InMemoryBackend::new("lt");
1811 let log = NoopChangeLog;
1812 let storage = TieredStorage::new(Box::new(hv.clone()), Box::new(lt.clone()), Box::new(log));
1813
1814 let hv_id = make_id("hv-key");
1815 let lt_id = make_id("lt-key");
1816 let payload = vec![0xABu8; 100];
1817
1818 lt.put_object(
1820 <_id,
1821 &Metadata::default(),
1822 stream::single(payload.clone()),
1823 Timestamp::now(),
1824 )
1825 .await
1826 .unwrap();
1827 let tombstone = Tombstone {
1828 target: lt_id.clone(),
1829 time_expires: None,
1830 };
1831 hv.compare_and_write(
1832 &hv_id,
1833 None,
1834 TieredWrite::Tombstone(tombstone),
1835 Timestamp::now(),
1836 )
1837 .await
1838 .unwrap();
1839
1840 let (_, _, s) = storage
1842 .get_object(&hv_id, Timestamp::now(), None)
1843 .await
1844 .unwrap()
1845 .unwrap();
1846 let body = stream::read_to_vec(s).await.unwrap();
1847 assert_eq!(body, payload);
1848
1849 storage
1851 .delete_object(&hv_id, Timestamp::now())
1852 .await
1853 .unwrap();
1854 storage.join().await;
1855 assert!(!hv.contains(&hv_id), "tombstone should be removed");
1856 assert!(!lt.contains(<_id), "lt object should be removed");
1857 }
1858
1859 #[tokio::test]
1862 async fn multi_chunk_large_object_chains_buffered_and_remaining() {
1863 let (storage, hv, lt, _) = make_tiered_storage();
1864 let id = make_id("multi-chunk");
1865
1866 let chunk_size = 512 * 1024; let chunk_count = 4; let stream: ClientStream = futures_util::stream::iter(
1871 (0..chunk_count).map(move |i| Ok(Bytes::from(vec![i as u8; chunk_size]))),
1872 )
1873 .boxed();
1874
1875 storage
1876 .put_object(&id, &Metadata::default(), stream, Timestamp::now())
1877 .await
1878 .unwrap();
1879
1880 let lt_id = hv.get(&id).expect_tombstone().target;
1882 let (_, lt_bytes) = lt.get(<_id).expect_object();
1883 assert_eq!(lt_bytes.len(), chunk_size * chunk_count);
1884
1885 for i in 0..chunk_count {
1887 let offset = i * chunk_size;
1888 assert!(
1889 lt_bytes[offset..offset + chunk_size]
1890 .iter()
1891 .all(|&b| b == i as u8),
1892 "data mismatch in chunk {i}"
1893 );
1894 }
1895 }
1896
1897 #[tokio::test]
1903 async fn written_cleanup_after_lost_cas_response() {
1904 let (storage, hv, lt, log) = make_tiered_storage();
1905 let id = make_id("obj");
1906
1907 let payload = vec![0xAAu8; 2 * 1024 * 1024];
1909 storage
1910 .put_object(
1911 &id,
1912 &Metadata::default(),
1913 stream::single(payload.clone()),
1914 Timestamp::now(),
1915 )
1916 .await
1917 .unwrap();
1918 let tombstone1 = hv.get(&id).expect_tombstone().target;
1919
1920 let broken_storage = TieredStorage::new(
1922 Box::new(TestBackend::with_inner(hv.clone(), FailCas(true))),
1923 Box::new(lt.clone()),
1924 Box::new(log.clone()),
1925 );
1926 broken_storage
1927 .put_object(
1928 &id,
1929 &Metadata::default(),
1930 stream::single(payload.clone()),
1931 Timestamp::now(),
1932 )
1933 .await
1934 .unwrap_err(); let tombstone2 = hv.get(&id).expect_tombstone().target;
1936 assert_ne!(tombstone1, tombstone2);
1937
1938 broken_storage.join().await;
1940 lt.get(&tombstone1).expect_not_found();
1941 lt.get(&tombstone2).expect_object();
1942
1943 broken_storage
1945 .delete_object(&id, Timestamp::now())
1946 .await
1947 .unwrap_err();
1948 hv.get(&id).expect_not_found();
1949 broken_storage.join().await;
1950 lt.get(&tombstone2).expect_not_found();
1951
1952 let id = make_id("obj2");
1954 storage
1955 .put_object(
1956 &id,
1957 &Metadata::default(),
1958 stream::single(payload.clone()),
1959 Timestamp::now(),
1960 )
1961 .await
1962 .unwrap();
1963 let tombstone3 = hv.get(&id).expect_tombstone().target;
1964
1965 broken_storage
1967 .put_object(
1968 &id,
1969 &Metadata::default(),
1970 stream::single(&b"small"[..]),
1971 Timestamp::now(),
1972 )
1973 .await
1974 .unwrap_err(); hv.get(&id).expect_object();
1976 broken_storage.join().await;
1977 lt.get(&tombstone3).expect_not_found();
1978 }
1979
1980 #[test]
1984 fn guard_dropped_outside_runtime_does_not_panic() {
1985 let manager = ChangeManager::new(
1986 Box::new(InMemoryBackend::new("hv")),
1987 Box::new(InMemoryBackend::new("lt")),
1988 Box::new(NoopChangeLog),
1989 );
1990
1991 let change = Change {
1992 id: make_id("object-key"),
1993 new: Some(make_id("cleanup-target")),
1994 old: None,
1995 cleanup_after: None,
1996 };
1997
1998 let guard = {
2001 let rt = tokio::runtime::Runtime::new().unwrap();
2002 rt.block_on(manager.record(change)).unwrap()
2003 };
2004
2005 drop(guard); }
2007
2008 #[tokio::test(start_paused = true)]
2013 async fn join_waits_for_cleanup_to_complete() {
2014 let (storage, _hv, _lt, _) = make_tiered_storage();
2015 let change = Change {
2016 id: make_id("object-key"),
2017 new: None,
2018 old: None,
2019 cleanup_after: None,
2020 };
2021 let mut guard = storage.record_change(change).await.unwrap();
2022
2023 tokio::spawn(async move {
2024 tokio::time::sleep(Duration::from_secs(10)).await;
2025 guard.advance(ChangePhase::Completed);
2026 drop(guard);
2027 });
2028
2029 let join_future = tokio::spawn(async move { storage.join().await });
2030
2031 tokio::time::sleep(Duration::from_secs(9)).await;
2032 assert!(!join_future.is_finished(), "finished before guard dropped");
2033
2034 tokio::time::sleep(Duration::from_secs(2)).await;
2035 assert!(join_future.is_finished(), "finish after guard drops");
2036 }
2037
2038 #[derive(Clone, Debug)]
2045 struct PauseAfterPut {
2046 paused: Arc<tokio::sync::Notify>,
2047 resume: Arc<tokio::sync::Notify>,
2048 }
2049
2050 #[async_trait::async_trait]
2051 impl Hooks for PauseAfterPut {
2052 async fn put_object(
2053 &self,
2054 inner: &InMemoryBackend,
2055 id: &ObjectId,
2056 metadata: &Metadata,
2057 stream: ClientStream,
2058 access_time: Timestamp,
2059 ) -> Result<PutResponse> {
2060 inner.put_object(id, metadata, stream, access_time).await?;
2061 self.paused.notify_one();
2062 self.resume.notified().await;
2063 Ok(())
2064 }
2065 }
2066
2067 #[tokio::test]
2070 async fn dropped_future_triggers_cleanup_and_log_entry_removed() {
2071 let paused = Arc::new(tokio::sync::Notify::new());
2072 let hooks = PauseAfterPut {
2073 paused: Arc::clone(&paused),
2074 resume: Arc::new(tokio::sync::Notify::new()),
2075 };
2076
2077 let lt_inner = InMemoryBackend::new("lt");
2078 let log = InMemoryChangeLog::default();
2079 let storage = TieredStorage::new(
2080 Box::new(InMemoryBackend::new("hv")),
2081 Box::new(TestBackend::with_inner(lt_inner.clone(), hooks)),
2082 Box::new(log.clone()),
2083 );
2084
2085 let id = make_id("drop-test");
2086 let metadata = Metadata::default();
2087 let payload = vec![0xABu8; 2 * 1024 * 1024]; tokio::select! {
2091 result = storage.put_object(&id, &metadata, stream::single(payload), Timestamp::now()) => {
2092 panic!("expected put to pause before completing, got: {result:?}");
2093 }
2094 _ = paused.notified() => {
2095 }
2097 }
2098
2099 storage.join().await;
2101
2102 assert!(lt_inner.is_empty(), "orphaned LT blob was not cleaned up");
2104
2105 let entries = log.scan().await.unwrap();
2107 assert!(
2108 entries.is_empty(),
2109 "changelog entry not removed after cleanup"
2110 );
2111 }
2112
2113 #[test]
2116 fn multipart_upload_id_roundtrip() {
2117 let id = TieredUploadId {
2118 revision: "my-key/01924a6f-7e28-7b9a-9c1d-abcdef123456".into(),
2119 upload_id: UploadId::new("upstream-upload-id-abc".into()).unwrap(),
2120 };
2121 let encoded: UploadId = id.clone().try_into().unwrap();
2122 let decoded: TieredUploadId = (&encoded.clone()).try_into().unwrap();
2123 assert_eq!(decoded, id);
2124 }
2125
2126 #[test]
2127 fn malformed_multipart_upload_ids_are_invalid_upload_ids() {
2128 let invalid_base64 = UploadId::new("%%%".into()).unwrap();
2129 let malformed_json = UploadId::new("bm90IGpzb24".into()).unwrap();
2130
2131 for upload_id in [&invalid_base64, &malformed_json] {
2132 let error = TieredUploadId::try_from(upload_id).unwrap_err();
2133 assert_eq!(error.kind(), ErrorKind::InvalidUploadId);
2134 }
2135 }
2136
2137 #[tokio::test]
2138 async fn multipart_single_part_roundtrip() {
2139 let (storage, hv, lt, _) = make_tiered_storage();
2140 let id = make_id("mp-single");
2141 let metadata = Metadata {
2142 content_type: "application/octet-stream".into(),
2143 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
2144 time_expires: Some(Timestamp::now() + Duration::from_hours(1)),
2145 ..Metadata::default()
2146 };
2147 let payload = vec![0xABu8; 2 * 1024 * 1024]; let upload_id = storage.initiate_multipart(&id, &metadata).await.unwrap();
2150
2151 let etag = storage
2152 .upload_part(
2153 &id,
2154 &upload_id,
2155 NonZeroU32::new(1).unwrap(),
2156 payload.len() as u64,
2157 None,
2158 stream::single(payload.clone()),
2159 )
2160 .await
2161 .unwrap();
2162
2163 let error = storage
2164 .complete_multipart(
2165 &id,
2166 &upload_id,
2167 vec![CompletedPart {
2168 part_number: NonZeroU32::new(1).unwrap(),
2169 etag,
2170 }],
2171 Timestamp::now(),
2172 )
2173 .await
2174 .unwrap();
2175 assert!(
2176 error.is_none(),
2177 "complete_multipart returned error: {error:?}"
2178 );
2179
2180 let (got_meta, _, s) = storage
2182 .get_object(&id, Timestamp::now(), None)
2183 .await
2184 .unwrap()
2185 .unwrap();
2186 let body = stream::read_to_vec(s).await.unwrap();
2187 assert_eq!(body, payload);
2188 assert_eq!(got_meta.content_type, "application/octet-stream");
2189
2190 let tombstone = hv.get(&id).expect_tombstone();
2192 assert!(
2193 tombstone.target.key().starts_with(id.key()),
2194 "tombstone target should be a revision key"
2195 );
2196 assert_eq!(tombstone.time_expires, metadata.time_expires);
2197 assert_eq!(
2198 lt.get(&tombstone.target).expect_object().0.time_expires,
2199 tombstone.time_expires
2200 );
2201 }
2202
2203 #[tokio::test]
2204 async fn multipart_upload() {
2205 let (storage, _hv, _lt, _) = make_tiered_storage();
2206 let id = make_id("multipart");
2207
2208 let upload_id = storage
2209 .initiate_multipart(&id, &Metadata::default())
2210 .await
2211 .unwrap();
2212
2213 let part1 = vec![0xAAu8; 512 * 1024];
2214 let part2 = vec![0xBBu8; 512 * 1024];
2215 let part3 = vec![0xCCu8; 512 * 1024];
2216
2217 let etag3 = storage
2218 .upload_part(
2219 &id,
2220 &upload_id,
2221 NonZeroU32::new(3).unwrap(),
2222 part3.len() as u64,
2223 None,
2224 stream::single(part3.clone()),
2225 )
2226 .await
2227 .unwrap();
2228 let etag2 = storage
2229 .upload_part(
2230 &id,
2231 &upload_id,
2232 NonZeroU32::new(2).unwrap(),
2233 part2.len() as u64,
2234 None,
2235 stream::single(part2.clone()),
2236 )
2237 .await
2238 .unwrap();
2239 let etag1 = storage
2240 .upload_part(
2241 &id,
2242 &upload_id,
2243 NonZeroU32::new(1).unwrap(),
2244 part1.len() as u64,
2245 None,
2246 stream::single(part1.clone()),
2247 )
2248 .await
2249 .unwrap();
2250
2251 let error = storage
2252 .complete_multipart(
2253 &id,
2254 &upload_id,
2255 vec![
2256 CompletedPart {
2257 part_number: NonZeroU32::new(1).unwrap(),
2258 etag: etag1,
2259 },
2260 CompletedPart {
2261 part_number: NonZeroU32::new(2).unwrap(),
2262 etag: etag2,
2263 },
2264 CompletedPart {
2265 part_number: NonZeroU32::new(3).unwrap(),
2266 etag: etag3,
2267 },
2268 ],
2269 Timestamp::now(),
2270 )
2271 .await
2272 .unwrap();
2273 assert!(error.is_none());
2274
2275 let (_, _, s) = storage
2276 .get_object(&id, Timestamp::now(), None)
2277 .await
2278 .unwrap()
2279 .unwrap();
2280 let body = stream::read_to_vec(s).await.unwrap();
2281
2282 let mut expected = Vec::new();
2283 expected.extend_from_slice(&part1);
2284 expected.extend_from_slice(&part2);
2285 expected.extend_from_slice(&part3);
2286 assert_eq!(body, expected);
2287 }
2288
2289 #[tokio::test]
2290 async fn multipart_abort() {
2291 let (storage, hv, _lt, _) = make_tiered_storage();
2292 let id = make_id("mp-abort");
2293
2294 let upload_id = storage
2295 .initiate_multipart(&id, &Metadata::default())
2296 .await
2297 .unwrap();
2298
2299 let payload = vec![0xABu8; 100];
2301 storage
2302 .upload_part(
2303 &id,
2304 &upload_id,
2305 NonZeroU32::new(1).unwrap(),
2306 payload.len() as u64,
2307 None,
2308 stream::single(payload),
2309 )
2310 .await
2311 .unwrap();
2312
2313 storage.abort_multipart(&id, &upload_id).await.unwrap();
2314
2315 hv.get(&id).expect_not_found();
2317
2318 assert!(
2320 storage
2321 .get_object(&id, Timestamp::now(), None)
2322 .await
2323 .unwrap()
2324 .is_none()
2325 );
2326 }
2327
2328 #[tokio::test]
2329 async fn multipart_list_parts() {
2330 let (storage, _hv, _lt, _) = make_tiered_storage();
2331 let id = make_id("mp-list");
2332
2333 let upload_id = storage
2334 .initiate_multipart(&id, &Metadata::default())
2335 .await
2336 .unwrap();
2337
2338 let part1 = vec![0xAAu8; 100];
2339 let part2 = vec![0xBBu8; 200];
2340 storage
2341 .upload_part(
2342 &id,
2343 &upload_id,
2344 NonZeroU32::new(1).unwrap(),
2345 part1.len() as u64,
2346 None,
2347 stream::single(part1),
2348 )
2349 .await
2350 .unwrap();
2351 storage
2352 .upload_part(
2353 &id,
2354 &upload_id,
2355 NonZeroU32::new(2).unwrap(),
2356 part2.len() as u64,
2357 None,
2358 stream::single(part2),
2359 )
2360 .await
2361 .unwrap();
2362
2363 let resp = storage
2364 .list_parts(&id, &upload_id, None, None)
2365 .await
2366 .unwrap();
2367 assert_eq!(resp.parts.len(), 2);
2368 assert_eq!(resp.parts[0].part_number.get(), 1);
2369 assert_eq!(resp.parts[0].size, 100);
2370 assert_eq!(resp.parts[1].part_number.get(), 2);
2371 assert_eq!(resp.parts[1].size, 200);
2372 }
2373
2374 #[tokio::test]
2375 async fn multipart_overwrites_existing_tombstone() {
2376 let (storage, hv, lt, _) = make_tiered_storage();
2377 let id = make_id("mp-overwrite");
2378
2379 let payload1 = vec![0xAAu8; 2 * 1024 * 1024];
2381 storage
2382 .put_object(
2383 &id,
2384 &Metadata::default(),
2385 stream::single(payload1),
2386 Timestamp::now(),
2387 )
2388 .await
2389 .unwrap();
2390 let old_lt_id = hv.get(&id).expect_tombstone().target;
2391
2392 let upload_id = storage
2394 .initiate_multipart(&id, &Metadata::default())
2395 .await
2396 .unwrap();
2397
2398 let payload2 = vec![0xBBu8; 2 * 1024 * 1024];
2399 let etag = storage
2400 .upload_part(
2401 &id,
2402 &upload_id,
2403 NonZeroU32::new(1).unwrap(),
2404 payload2.len() as u64,
2405 None,
2406 stream::single(payload2.clone()),
2407 )
2408 .await
2409 .unwrap();
2410
2411 let lt_id = hv.get(&id).expect_tombstone().target;
2414 assert_eq!(old_lt_id, lt_id);
2415
2416 let error = storage
2417 .complete_multipart(
2418 &id,
2419 &upload_id,
2420 vec![CompletedPart {
2421 part_number: NonZeroU32::new(1).unwrap(),
2422 etag,
2423 }],
2424 Timestamp::now(),
2425 )
2426 .await
2427 .unwrap();
2428 assert!(error.is_none());
2429
2430 let new_lt_id = hv.get(&id).expect_tombstone().target;
2432 assert_ne!(old_lt_id, new_lt_id);
2433
2434 storage.join().await;
2436
2437 lt.get(&old_lt_id).expect_not_found();
2439 lt.get(&new_lt_id).expect_object();
2440
2441 let (_, _, s) = storage
2443 .get_object(&id, Timestamp::now(), None)
2444 .await
2445 .unwrap()
2446 .unwrap();
2447 let body = stream::read_to_vec(s).await.unwrap();
2448 assert_eq!(body, payload2);
2449 }
2450
2451 #[derive(Debug)]
2457 struct CompleteMultipartButReturnError;
2458
2459 #[async_trait::async_trait]
2460 impl Hooks for CompleteMultipartButReturnError {
2461 async fn complete_multipart(
2462 &self,
2463 inner: &InMemoryBackend,
2464 id: &ObjectId,
2465 upload_id: &UploadId,
2466 parts: Vec<CompletedPart>,
2467 access_time: Timestamp,
2468 ) -> Result<CompleteMultipartResponse> {
2469 inner
2470 .complete_multipart(id, upload_id, parts, access_time)
2471 .await
2472 .unwrap();
2473 Err(Error::with_source(
2474 ErrorKind::BackendFailure,
2475 std::io::Error::new(
2476 std::io::ErrorKind::TimedOut,
2477 "simulated network error on complete_multipart",
2478 ),
2479 ))
2480 }
2481
2482 async fn get_metadata(
2483 &self,
2484 _inner: &InMemoryBackend,
2485 _id: &ObjectId,
2486 _access_time: Timestamp,
2487 ) -> Result<MetadataResponse> {
2488 Err(Error::with_source(
2489 ErrorKind::BackendFailure,
2490 std::io::Error::new(
2491 std::io::ErrorKind::TimedOut,
2492 "simulated network error on get_metadata",
2493 ),
2494 ))
2495 }
2496 }
2497
2498 #[tokio::test]
2502 async fn cleans_up_orphan_after_failed_multipart_complete() {
2503 let hv = InMemoryBackend::new("hv");
2504 let lt_inner = InMemoryBackend::new("lt");
2505 let log = InMemoryChangeLog::default();
2506 let storage = TieredStorage::new(
2507 Box::new(hv.clone()),
2508 Box::new(TestBackend::with_inner(
2509 lt_inner.clone(),
2510 CompleteMultipartButReturnError {},
2511 )),
2512 Box::new(log.clone()),
2513 );
2514
2515 let id = make_id("mp-orphan");
2516 let upload_id = storage
2517 .initiate_multipart(&id, &Metadata::default())
2518 .await
2519 .unwrap();
2520
2521 let tiered_id: TieredUploadId = (&upload_id).try_into().unwrap();
2522 let physical = ObjectId {
2523 context: id.context.clone(),
2524 key: tiered_id.revision,
2525 };
2526
2527 let payload = vec![0xABu8; 2 * 1024 * 1024];
2528 let etag = storage
2529 .upload_part(
2530 &id,
2531 &upload_id,
2532 NonZeroU32::new(1).unwrap(),
2533 payload.len() as u64,
2534 None,
2535 stream::single(payload),
2536 )
2537 .await
2538 .unwrap();
2539
2540 let result = storage
2541 .complete_multipart(
2542 &id,
2543 &upload_id,
2544 vec![CompletedPart {
2545 part_number: NonZeroU32::new(1).unwrap(),
2546 etag,
2547 }],
2548 Timestamp::now(),
2549 )
2550 .await;
2551 assert!(result.is_err());
2552 storage.join().await;
2553
2554 lt_inner.get(&physical).expect_object();
2557 hv.get(&id).expect_not_found();
2558
2559 log.expire_all();
2561 let manager = ChangeManager::new(
2562 Box::new(hv.clone()),
2563 Box::new(lt_inner.clone()),
2564 Box::new(log.clone()),
2565 );
2566 manager.recover().await.unwrap();
2567
2568 lt_inner.get(&physical).expect_not_found();
2570 let remaining = log.scan().await.unwrap();
2572 assert!(remaining.is_empty());
2573 }
2574
2575 #[derive(Debug)]
2576 struct FailOnFirstCompleteMultipartAttempt {
2577 attempt: Mutex<u32>,
2578 }
2579
2580 impl FailOnFirstCompleteMultipartAttempt {
2581 fn new() -> Self {
2582 Self {
2583 attempt: Mutex::new(0),
2584 }
2585 }
2586 }
2587
2588 #[async_trait::async_trait]
2589 impl Hooks for FailOnFirstCompleteMultipartAttempt {
2590 async fn complete_multipart(
2591 &self,
2592 inner: &InMemoryBackend,
2593 id: &ObjectId,
2594 upload_id: &UploadId,
2595 parts: Vec<CompletedPart>,
2596 access_time: Timestamp,
2597 ) -> Result<CompleteMultipartResponse> {
2598 let mut attempt = self.attempt.lock().await;
2599 *attempt += 1;
2600 if *attempt == 1 {
2601 Err(Error::with_source(
2602 ErrorKind::BackendFailure,
2603 std::io::Error::new(std::io::ErrorKind::TimedOut, "simulated network error"),
2604 ))
2605 } else {
2606 Ok(inner
2607 .complete_multipart(id, upload_id, parts, access_time)
2608 .await
2609 .unwrap())
2610 }
2611 }
2612 }
2613
2614 #[tokio::test]
2619 async fn multipart_complete_succeeds_on_retry_and_leaves_state_consistent() {
2620 let hv = InMemoryBackend::new("hv");
2621 let lt_inner = InMemoryBackend::new("lt");
2622 let log = InMemoryChangeLog::default();
2623 let storage = TieredStorage::new(
2624 Box::new(hv.clone()),
2625 Box::new(TestBackend::with_inner(
2626 lt_inner.clone(),
2627 FailOnFirstCompleteMultipartAttempt::new(),
2628 )),
2629 Box::new(log.clone()),
2630 );
2631
2632 let id = make_id("mp-retry");
2633 let upload_id = storage
2634 .initiate_multipart(&id, &Metadata::default())
2635 .await
2636 .unwrap();
2637
2638 let tiered_id: TieredUploadId = (&upload_id).try_into().unwrap();
2639 let physical = ObjectId {
2640 context: id.context.clone(),
2641 key: tiered_id.revision,
2642 };
2643
2644 let payload = vec![0xABu8; 2 * 1024 * 1024];
2645 let etag = storage
2646 .upload_part(
2647 &id,
2648 &upload_id,
2649 NonZeroU32::new(1).unwrap(),
2650 payload.len() as u64,
2651 None,
2652 stream::single(payload.clone()),
2653 )
2654 .await
2655 .unwrap();
2656
2657 let result = storage
2659 .complete_multipart(
2660 &id,
2661 &upload_id,
2662 vec![CompletedPart {
2663 part_number: NonZeroU32::new(1).unwrap(),
2664 etag: etag.clone(),
2665 }],
2666 Timestamp::now(),
2667 )
2668 .await;
2669 assert!(result.is_err());
2670 storage.join().await;
2671
2672 let result = storage
2674 .complete_multipart(
2675 &id,
2676 &upload_id,
2677 vec![CompletedPart {
2678 part_number: NonZeroU32::new(1).unwrap(),
2679 etag,
2680 }],
2681 Timestamp::now(),
2682 )
2683 .await;
2684 assert!(result.is_ok());
2685 storage.join().await;
2686
2687 let (_, _, s) = storage
2689 .get_object(&id, Timestamp::now(), None)
2690 .await
2691 .unwrap()
2692 .unwrap();
2693 let body = stream::read_to_vec(s).await.unwrap();
2694 assert_eq!(body, payload);
2695
2696 log.expire_all();
2698 let manager = ChangeManager::new(
2699 Box::new(hv.clone()),
2700 Box::new(lt_inner.clone()),
2701 Box::new(log.clone()),
2702 );
2703 manager.recover().await.unwrap();
2704
2705 lt_inner.get(&physical).expect_object();
2707 let tombstone = hv.get(&id).expect_tombstone();
2709 assert_eq!(tombstone.target, physical);
2710 let remaining = log.scan().await.unwrap();
2712 assert!(remaining.is_empty());
2713
2714 let (_, _, s) = storage
2716 .get_object(&id, Timestamp::now(), None)
2717 .await
2718 .unwrap()
2719 .unwrap();
2720 let body = stream::read_to_vec(s).await.unwrap();
2721 assert_eq!(body, payload);
2722 }
2723
2724 #[derive(Debug)]
2725 struct FailOnFirstGetMetadataAttempt {
2726 attempt: Mutex<u32>,
2727 }
2728
2729 impl FailOnFirstGetMetadataAttempt {
2730 fn new() -> Self {
2731 Self {
2732 attempt: Mutex::new(0),
2733 }
2734 }
2735 }
2736
2737 #[async_trait::async_trait]
2738 impl Hooks for FailOnFirstGetMetadataAttempt {
2739 async fn get_metadata(
2740 &self,
2741 inner: &InMemoryBackend,
2742 id: &ObjectId,
2743 access_time: Timestamp,
2744 ) -> Result<MetadataResponse> {
2745 let mut attempt = self.attempt.lock().await;
2746 *attempt += 1;
2747 if *attempt == 1 {
2748 Err(Error::with_source(
2749 ErrorKind::BackendFailure,
2750 std::io::Error::new(std::io::ErrorKind::TimedOut, "simulated network error"),
2751 ))
2752 } else {
2753 inner.get_metadata(id, access_time).await
2754 }
2755 }
2756 }
2757
2758 #[tokio::test]
2765 async fn multipart_complete_succeeds_on_retry_if_get_metadata_errs_and_leaves_state_consistent()
2766 {
2767 let hv = InMemoryBackend::new("hv");
2768 let lt_inner = InMemoryBackend::new("lt");
2769 let log = InMemoryChangeLog::default();
2770 let storage = TieredStorage::new(
2771 Box::new(hv.clone()),
2772 Box::new(TestBackend::with_inner(
2773 lt_inner.clone(),
2774 FailOnFirstGetMetadataAttempt::new(),
2775 )),
2776 Box::new(log.clone()),
2777 );
2778
2779 let id = make_id("mp-retry-meta");
2780 let upload_id = storage
2781 .initiate_multipart(&id, &Metadata::default())
2782 .await
2783 .unwrap();
2784
2785 let tiered_id: TieredUploadId = (&upload_id).try_into().unwrap();
2786 let physical = ObjectId {
2787 context: id.context.clone(),
2788 key: tiered_id.revision,
2789 };
2790
2791 let payload = vec![0xABu8; 2 * 1024 * 1024];
2792 let etag = storage
2793 .upload_part(
2794 &id,
2795 &upload_id,
2796 NonZeroU32::new(1).unwrap(),
2797 payload.len() as u64,
2798 None,
2799 stream::single(payload.clone()),
2800 )
2801 .await
2802 .unwrap();
2803
2804 let result = storage
2807 .complete_multipart(
2808 &id,
2809 &upload_id,
2810 vec![CompletedPart {
2811 part_number: NonZeroU32::new(1).unwrap(),
2812 etag: etag.clone(),
2813 }],
2814 Timestamp::now(),
2815 )
2816 .await;
2817 assert!(result.is_err());
2818 storage.join().await;
2819
2820 let result = storage
2822 .complete_multipart(
2823 &id,
2824 &upload_id,
2825 vec![CompletedPart {
2826 part_number: NonZeroU32::new(1).unwrap(),
2827 etag,
2828 }],
2829 Timestamp::now(),
2830 )
2831 .await;
2832 assert!(result.is_ok());
2833 storage.join().await;
2834
2835 let (_, _, s) = storage
2837 .get_object(&id, Timestamp::now(), None)
2838 .await
2839 .unwrap()
2840 .unwrap();
2841 let body = stream::read_to_vec(s).await.unwrap();
2842 assert_eq!(body, payload);
2843
2844 log.expire_all();
2846 let manager = ChangeManager::new(
2847 Box::new(hv.clone()),
2848 Box::new(lt_inner.clone()),
2849 Box::new(log.clone()),
2850 );
2851 manager.recover().await.unwrap();
2852
2853 lt_inner.get(&physical).expect_object();
2855 let tombstone = hv.get(&id).expect_tombstone();
2857 assert_eq!(tombstone.target, physical);
2858 let remaining = log.scan().await.unwrap();
2860 assert!(remaining.is_empty());
2861
2862 let (_, _, s) = storage
2864 .get_object(&id, Timestamp::now(), None)
2865 .await
2866 .unwrap()
2867 .unwrap();
2868 let body = stream::read_to_vec(s).await.unwrap();
2869 assert_eq!(body, payload);
2870 }
2871}