1use std::sync::Arc;
101use std::sync::atomic::{AtomicU64, Ordering};
102use std::time::{Duration, SystemTime};
103
104use base64::Engine as _;
105use bytes::Bytes;
106use futures_util::{Stream, StreamExt};
107use objectstore_types::metadata::Metadata;
108use objectstore_types::range::ByteRange;
109use serde::{Deserialize, Serialize};
110
111use crate::backend::changelog::{Change, ChangeGuard, ChangeLog, ChangeManager, ChangePhase};
112use crate::backend::common::{
113 Backend, DeleteResponse, GetResponse, HighVolumeBackend, MetadataResponse,
114 MultipartUploadBackend, PutResponse, TieredGet, TieredMetadata, TieredWrite, Tombstone,
115};
116use crate::backend::{HighVolumeStorageConfig, MultipartUploadStorageConfig};
117use crate::error::{Error, Result};
118use crate::id::ObjectId;
119use crate::multipart::{
120 AbortMultipartResponse, CompleteMultipartResponse, CompletedPart, InitiateMultipartResponse,
121 ListPartsResponse, PartNumber, UploadId, UploadPartResponse,
122};
123use crate::stream::{ClientStream, SizedPeek};
124
125const BACKEND_SIZE_THRESHOLD: usize = 1024 * 1024; const MULTIPART_COMPLETE_CLEANUP_DELAY: Duration = Duration::from_hours(24);
133
134fn new_long_term_revision(id: &ObjectId) -> ObjectId {
140 ObjectId {
141 context: id.context.clone(),
142 key: format!("{}/{}", id.key, uuid::Uuid::now_v7()),
143 }
144}
145
146#[derive(Debug, Clone, Deserialize, Serialize)]
167pub struct TieredStorageConfig {
168 pub high_volume: HighVolumeStorageConfig,
173 pub long_term: MultipartUploadStorageConfig,
177}
178
179#[derive(Debug)]
229pub struct TieredStorage {
230 inner: Arc<ChangeManager>,
231}
232
233impl TieredStorage {
234 pub fn new(
236 high_volume: Box<dyn HighVolumeBackend>,
237 long_term: Box<dyn MultipartUploadBackend>,
238 changelog: Box<dyn ChangeLog>,
239 ) -> Self {
240 let inner = ChangeManager::new(high_volume, long_term, changelog);
241 tokio::spawn(inner.clone().recover());
244 Self { inner }
245 }
246
247 async fn record_change(&self, change: Change) -> Result<ChangeGuard> {
249 self.inner.clone().record(change).await
250 }
251
252 async fn record_assembling(&self, change: Change) -> Result<ChangeGuard> {
255 self.inner.clone().record_assembling(change).await
256 }
257
258 fn backend_type(&self, choice: &BackendChoice) -> &'static str {
260 match choice {
261 BackendChoice::HighVolume => self.inner.high_volume.name(),
262 BackendChoice::LongTerm => self.inner.long_term.name(),
263 }
264 }
265
266 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
271 async fn put_high_volume(
272 &self,
273 id: &ObjectId,
274 metadata: &Metadata,
275 payload: Bytes,
276 ) -> Result<()> {
277 let tombstone_opt = self
278 .inner
279 .high_volume
280 .put_non_tombstone(id, metadata, payload.clone())
281 .await?;
282
283 let Some(Tombstone { target, .. }) = tombstone_opt else {
284 return Ok(());
286 };
287
288 let mut guard = self
290 .record_change(Change {
291 id: id.clone(),
292 new: None,
293 old: Some(target.clone()),
294 cleanup_after: None,
295 })
296 .await?;
297
298 let write = TieredWrite::Object(metadata.clone(), payload);
299 guard.advance(ChangePhase::Written);
300
301 let written = self
302 .inner
303 .high_volume
304 .compare_and_write(id, Some(&target), write)
305 .await?;
306
307 guard.advance(ChangePhase::compare_and_write(written));
309
310 Ok(())
311 }
312
313 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
318 async fn put_long_term(
319 &self,
320 id: &ObjectId,
321 metadata: &Metadata,
322 stream: ClientStream,
323 ) -> Result<()> {
324 let current = match self.inner.high_volume.get_tiered_metadata(id).await? {
326 TieredMetadata::Tombstone(t) => Some(t.target),
327 _ => None,
328 };
329
330 let new = new_long_term_revision(id);
332 let mut guard = self
333 .record_change(Change {
334 id: id.clone(),
335 new: Some(new.clone()),
336 old: current.clone(),
337 cleanup_after: None,
338 })
339 .await?;
340
341 self.inner
342 .long_term
343 .put_object(&new, metadata, stream)
344 .await?;
345 guard.advance(ChangePhase::Written);
346
347 let tombstone = Tombstone {
349 target: new.clone(),
350 expiration_policy: metadata.expiration_policy,
351 };
352 let written = self
353 .inner
354 .high_volume
355 .compare_and_write(id, current.as_ref(), TieredWrite::Tombstone(tombstone))
356 .await?;
357
358 guard.advance(ChangePhase::compare_and_write(written));
360
361 Ok(())
362 }
363}
364
365#[async_trait::async_trait]
366impl Backend for TieredStorage {
367 fn name(&self) -> &'static str {
368 "tiered"
369 }
370
371 fn as_multipart_upload_backend(&self) -> Result<&dyn MultipartUploadBackend> {
372 Ok(self)
373 }
374
375 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
376 async fn put_object(
377 &self,
378 id: &ObjectId,
379 metadata: &Metadata,
380 stream: ClientStream,
381 ) -> Result<PutResponse> {
382 let timer = objectstore_metrics::timer!("put.latency", usecase = id.usecase().to_owned());
383 if metadata.origin.is_none() {
384 objectstore_metrics::count!("put.origin_missing", usecase = id.usecase().to_owned());
385 }
386
387 let peeked = SizedPeek::new(stream, BACKEND_SIZE_THRESHOLD).await?;
388 objectstore_metrics::record!(
389 "put.first_chunk.latency" = timer.elapsed(),
390 usecase = id.usecase().to_owned(),
391 complete = if peeked.is_exhausted() { "yes" } else { "no" },
392 );
393
394 let (backend_choice, stored_size) = if peeked.is_exhausted() {
395 let payload = peeked.into_bytes().await?;
396 let payload_len = payload.len() as u64;
397 self.put_high_volume(id, metadata, payload).await?;
398 (BackendChoice::HighVolume, payload_len)
399 } else {
400 let (stored_size, stream) = counting_stream(peeked.into_stream());
401 self.put_long_term(id, metadata, stream.boxed()).await?;
402 (BackendChoice::LongTerm, stored_size.load(Ordering::Acquire))
403 };
404
405 let backend_ty = self.backend_type(&backend_choice);
406 timer
407 .tag("backend_choice", backend_choice.as_str())
408 .tag("backend_type", backend_ty)
409 .record();
410 objectstore_metrics::record!(
411 "put.size" = stored_size,
412 usecase = id.usecase().to_owned(),
413 backend_choice = backend_choice.as_str(),
414 backend_type = backend_ty,
415 upload_type = "direct",
416 );
417
418 Ok(())
419 }
420
421 #[tracing::instrument(level = "debug", skip(self))]
422 async fn get_object(&self, id: &ObjectId, range: Option<ByteRange>) -> Result<GetResponse> {
423 let timer = objectstore_metrics::timer!(
424 "get.latency.pre-response",
425 usecase = id.usecase().to_owned(),
426 );
427
428 let hv_result = self.inner.high_volume.get_tiered_object(id, range).await?;
429 let (result, backend_choice) = match hv_result {
430 TieredGet::NotFound => (None, BackendChoice::HighVolume),
431 TieredGet::Object(metadata, content_range, stream) => (
432 Some((metadata, content_range, stream)),
433 BackendChoice::HighVolume,
434 ),
435 TieredGet::Tombstone(tombstone) => (
436 self.inner
437 .long_term
438 .get_object(&tombstone.target, range)
439 .await?,
440 BackendChoice::LongTerm,
441 ),
442 };
443
444 let backend_type = self.backend_type(&backend_choice);
445 timer
446 .tag("backend_choice", backend_choice.as_str())
447 .tag("backend_type", backend_type)
448 .record();
449
450 if let Some((ref metadata, ref content_range, _)) = result {
451 let size = content_range.map(|cr| cr.len() as usize).or(metadata.size);
452 if let Some(size) = size {
453 objectstore_metrics::record!(
454 "get.size" = size,
455 usecase = id.usecase().to_owned(),
456 backend_choice = backend_choice.as_str(),
457 backend_type = backend_type,
458 );
459 }
460 }
461
462 Ok(result)
463 }
464
465 #[tracing::instrument(level = "debug", skip(self))]
466 async fn get_metadata(&self, id: &ObjectId) -> Result<MetadataResponse> {
467 let timer = objectstore_metrics::timer!("head.latency", usecase = id.usecase().to_owned());
468
469 let hv_result = self.inner.high_volume.get_tiered_metadata(id).await?;
470 let (result, backend_choice) = match hv_result {
471 TieredMetadata::NotFound => (None, BackendChoice::HighVolume),
472 TieredMetadata::Object(metadata) => (Some(metadata), BackendChoice::HighVolume),
473 TieredMetadata::Tombstone(tombstone) => (
474 self.inner.long_term.get_metadata(&tombstone.target).await?,
475 BackendChoice::LongTerm,
476 ),
477 };
478
479 timer
480 .tag("backend_choice", backend_choice.as_str())
481 .tag("backend_type", self.backend_type(&backend_choice))
482 .record();
483
484 Ok(result)
485 }
486
487 #[tracing::instrument(level = "debug", skip(self))]
488 async fn delete_object(&self, id: &ObjectId) -> Result<DeleteResponse> {
489 let timer =
490 objectstore_metrics::timer!("delete.latency", usecase = id.usecase().to_owned());
491
492 let mut backend_choice = BackendChoice::HighVolume;
493
494 if let Some(tombstone) = self.inner.high_volume.delete_non_tombstone(id).await? {
495 backend_choice = BackendChoice::LongTerm;
496
497 let mut guard = self
498 .record_change(Change {
499 id: id.clone(),
500 new: None,
501 old: Some(tombstone.target.clone()),
502 cleanup_after: None,
503 })
504 .await?;
505 guard.advance(ChangePhase::Written);
506
507 let deleted = self
509 .inner
510 .high_volume
511 .compare_and_write(id, Some(&tombstone.target), TieredWrite::Delete)
512 .await?;
513
514 guard.advance(ChangePhase::compare_and_write(deleted));
516 }
517
518 timer
519 .tag("backend_choice", backend_choice.as_str())
520 .tag("backend_type", self.backend_type(&backend_choice))
521 .record();
522
523 Ok(())
524 }
525
526 async fn join(&self) {
527 self.inner.tracker.close();
528 tokio::join!(
529 self.inner.high_volume.join(),
530 self.inner.long_term.join(),
531 self.inner.tracker.wait()
532 );
533 }
534}
535
536#[derive(Debug)]
537enum BackendChoice {
538 HighVolume,
539 LongTerm,
540}
541
542impl BackendChoice {
543 fn as_str(&self) -> &'static str {
544 match self {
545 BackendChoice::HighVolume => "high-volume",
546 BackendChoice::LongTerm => "long-term",
547 }
548 }
549}
550
551impl std::fmt::Display for BackendChoice {
552 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553 f.write_str(self.as_str())
554 }
555}
556
557fn counting_stream<S, E>(stream: S) -> (Arc<AtomicU64>, impl Stream<Item = Result<Bytes, E>>)
562where
563 S: Stream<Item = Result<Bytes, E>>,
564{
565 let counter = Arc::new(AtomicU64::new(0));
566
567 (
568 counter.clone(),
569 stream.inspect(move |res| {
570 if let Ok(chunk) = res {
571 counter.fetch_add(chunk.len() as u64, Ordering::Relaxed);
572 }
573 }),
574 )
575}
576
577#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
579struct TieredUploadId {
580 revision: String,
581 upload_id: UploadId,
582}
583
584impl TryInto<UploadId> for TieredUploadId {
585 type Error = Error;
586
587 fn try_into(self) -> Result<UploadId, Self::Error> {
588 let json =
589 serde_json::to_vec(&self).map_err(|e| Error::serde("encoding multipart token", e))?;
590 Ok(UploadId::new(
591 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json),
592 )?)
593 }
594}
595
596impl TryFrom<&UploadId> for TieredUploadId {
597 type Error = Error;
598
599 fn try_from(value: &UploadId) -> Result<Self, Self::Error> {
600 let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
601 .decode(value.as_bytes())
602 .map_err(|e| Error::generic(format!("invalid multipart upload ID: {e}")))?;
603 serde_json::from_slice(&json).map_err(|e| Error::serde("decoding multipart token", e))
604 }
605}
606
607#[async_trait::async_trait]
608impl MultipartUploadBackend for TieredStorage {
609 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
610 async fn initiate_multipart(
611 &self,
612 id: &ObjectId,
613 metadata: &Metadata,
614 ) -> Result<InitiateMultipartResponse> {
615 let timer = objectstore_metrics::timer!(
616 "multipart.initiate.latency",
617 usecase = id.usecase().to_owned(),
618 );
619 let physical = new_long_term_revision(id);
620
621 let upload_id = self
622 .inner
623 .long_term
624 .initiate_multipart(&physical, metadata)
625 .await?;
626
627 let id = TieredUploadId {
628 revision: physical.key,
629 upload_id,
630 };
631 let id = id.try_into()?;
632
633 timer.record();
634 Ok(id)
635 }
636
637 #[tracing::instrument(level = "debug", fields(?id, part_number, content_length), skip_all)]
638 async fn upload_part(
639 &self,
640 id: &ObjectId,
641 upload_id: &UploadId,
642 part_number: PartNumber,
643 content_length: u64,
644 content_md5: Option<&str>,
645 body: ClientStream,
646 ) -> Result<UploadPartResponse> {
647 let timer = objectstore_metrics::timer!(
648 "multipart.upload_part.latency",
649 usecase = id.usecase().to_owned(),
650 );
651 let tiered: TieredUploadId = upload_id.try_into()?;
652
653 let physical = ObjectId {
654 context: id.context.clone(),
655 key: tiered.revision,
656 };
657
658 let etag = self
659 .inner
660 .long_term
661 .upload_part(
662 &physical,
663 &tiered.upload_id,
664 part_number,
665 content_length,
666 content_md5,
667 body,
668 )
669 .await?;
670
671 timer.record();
672 objectstore_metrics::record!(
673 "multipart.upload_part.size" = content_length,
674 usecase = id.usecase().to_owned(),
675 );
676
677 Ok(etag)
678 }
679
680 #[tracing::instrument(level = "debug", skip(self, upload_id))]
681 async fn list_parts(
682 &self,
683 id: &ObjectId,
684 upload_id: &UploadId,
685 max_parts: Option<u32>,
686 part_number_marker: Option<PartNumber>,
687 ) -> Result<ListPartsResponse> {
688 let timer = objectstore_metrics::timer!(
689 "multipart.list_parts.latency",
690 usecase = id.usecase().to_owned(),
691 );
692 let tiered: TieredUploadId = upload_id.try_into()?;
693
694 let physical = ObjectId {
695 context: id.context.clone(),
696 key: tiered.revision,
697 };
698
699 let response = self
700 .inner
701 .long_term
702 .list_parts(&physical, &tiered.upload_id, max_parts, part_number_marker)
703 .await?;
704
705 timer.record();
706 Ok(response)
707 }
708
709 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
710 async fn abort_multipart(
711 &self,
712 id: &ObjectId,
713 upload_id: &UploadId,
714 ) -> Result<AbortMultipartResponse> {
715 let timer = objectstore_metrics::timer!(
716 "multipart.abort.latency",
717 usecase = id.usecase().to_owned(),
718 );
719 let tiered: TieredUploadId = upload_id.try_into()?;
720
721 let physical = ObjectId {
722 context: id.context.clone(),
723 key: tiered.revision,
724 };
725
726 let () = self
727 .inner
728 .long_term
729 .abort_multipart(&physical, &tiered.upload_id)
730 .await?;
731
732 timer.record();
733 Ok(())
734 }
735
736 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
737 async fn complete_multipart(
738 &self,
739 id: &ObjectId,
740 upload_id: &UploadId,
741 parts: Vec<CompletedPart>,
742 ) -> Result<CompleteMultipartResponse> {
743 let timer = objectstore_metrics::timer!(
744 "multipart.complete.latency",
745 usecase = id.usecase().to_owned(),
746 );
747 let part_count = parts.len();
748 let tiered: TieredUploadId = upload_id.try_into()?;
749
750 let physical = ObjectId {
751 context: id.context.clone(),
752 key: tiered.revision,
753 };
754
755 let current = match self.inner.high_volume.get_tiered_metadata(id).await? {
757 TieredMetadata::Tombstone(t) if t.target == physical => {
759 timer.record();
760 return Ok(None);
761 }
762 TieredMetadata::Tombstone(t) => Some(t.target),
763 _ => None,
764 };
765
766 let mut guard = self
769 .record_assembling(Change {
770 id: id.clone(),
771 new: Some(physical.clone()),
772 old: current.clone(),
773 cleanup_after: Some(SystemTime::now() + MULTIPART_COMPLETE_CLEANUP_DELAY),
774 })
775 .await?;
776
777 let maybe_complete_multipart_err = match self
779 .inner
780 .long_term
781 .complete_multipart(&physical, &tiered.upload_id, parts)
782 .await
783 {
784 Ok(error) => {
787 if error.is_some() {
788 return Ok(error);
789 }
790 None
791 }
792 Err(err) => Some(err),
798 };
799
800 let metadata = self.inner.long_term.get_metadata(&physical).await;
807
808 let metadata = match (metadata, maybe_complete_multipart_err) {
809 (Ok(Some(metadata)), _) => metadata,
811 (Ok(None), Some(err)) => return Err(err),
813 (Ok(None), None) => {
816 objectstore_log::error!(
817 id = ?id,
818 upload_id = ?upload_id,
819 physical = ?physical,
820 "complete_multipart call succeeded on long_term backend, but subsequent get_metadata found no object"
821 );
822 return Err(Error::generic(
823 "completed multipart object not found in long-term storage",
824 ));
825 }
826 (Err(get_metadata_err), maybe_complete_multipart_err) => {
828 return Err(maybe_complete_multipart_err.unwrap_or(get_metadata_err));
832 }
833 };
834
835 let tombstone = Tombstone {
837 target: physical.clone(),
838 expiration_policy: metadata.expiration_policy,
839 };
840 let written = self
841 .inner
842 .high_volume
843 .compare_and_write(id, current.as_ref(), TieredWrite::Tombstone(tombstone))
844 .await?;
845
846 guard.advance(ChangePhase::compare_and_write(written));
848
849 timer.record();
850 objectstore_metrics::record!(
851 "multipart.complete.part_count" = part_count as u64,
852 usecase = id.usecase().to_owned(),
853 );
854 if let Some(size) = metadata.size {
855 objectstore_metrics::record!(
856 "put.size" = size as u64,
857 usecase = id.usecase().to_owned(),
858 backend_choice = BackendChoice::LongTerm.as_str(),
859 backend_type = self.backend_type(&BackendChoice::LongTerm),
860 upload_type = "multipart",
861 );
862 }
863
864 Ok(None)
865 }
866}
867
868#[cfg(test)]
869mod tests {
870 use std::num::NonZeroU32;
871
872 use futures::lock::Mutex;
873 use objectstore_types::metadata::{ExpirationPolicy, Metadata};
874 use objectstore_types::scope::{Scope, Scopes};
875
876 use super::*;
877 use crate::backend::changelog::{InMemoryChangeLog, NoopChangeLog};
878 use crate::backend::in_memory::InMemoryBackend;
879 use crate::backend::testing::{Hooks, TestBackend};
880 use crate::error::Error;
881 use crate::id::ObjectContext;
882
883 use crate::stream::{self, ClientStream};
884
885 fn make_context() -> ObjectContext {
886 ObjectContext {
887 usecase: "testing".into(),
888 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
889 }
890 }
891
892 fn make_id(key: &str) -> ObjectId {
893 ObjectId::new(make_context(), key.into())
894 }
895
896 fn make_tiered_storage() -> (
897 TieredStorage,
898 InMemoryBackend,
899 InMemoryBackend,
900 InMemoryChangeLog,
901 ) {
902 let hv = InMemoryBackend::new("in-memory-hv");
903 let lt = InMemoryBackend::new("in-memory-lt");
904 let changelog = InMemoryChangeLog::default();
905 let storage = TieredStorage::new(
906 Box::new(hv.clone()),
907 Box::new(lt.clone()),
908 Box::new(changelog.clone()),
909 );
910 (storage, hv, lt, changelog)
911 }
912
913 #[test]
916 fn revision_id_preserves_context() {
917 let id = make_id("my-key");
918 let revised = new_long_term_revision(&id);
919 assert_eq!(revised.context, id.context);
920 assert!(
921 revised.key.starts_with("my-key/"),
922 "revised key should have /<uuid> suffix, got: {}",
923 revised.key
924 );
925 }
926
927 #[test]
928 fn revision_id_roundtrips_storage_path() {
929 let id = make_id("original");
930 let revised = new_long_term_revision(&id);
931 let path = revised.as_storage_path().to_string();
932 let parsed = ObjectId::from_storage_path(&path)
933 .unwrap_or_else(|| panic!("failed to parse '{path}'"));
934 assert_eq!(parsed, revised);
935 }
936
937 #[test]
938 fn revision_id_is_unique() {
939 let id = make_id("base-key");
940 let a = new_long_term_revision(&id);
941 let b = new_long_term_revision(&id);
942 assert_ne!(a.key, b.key, "two calls should produce different keys");
943 }
944
945 #[tokio::test]
948 async fn get_nonexistent_returns_none() {
949 let (storage, _hv, _lt, _) = make_tiered_storage();
950 let id = make_id("does-not-exist");
951
952 assert!(storage.get_object(&id, None).await.unwrap().is_none());
953 assert!(storage.get_metadata(&id).await.unwrap().is_none());
954 }
955
956 #[tokio::test]
957 async fn delete_nonexistent_succeeds() {
958 let (storage, _hv, _lt, _) = make_tiered_storage();
959 let id = make_id("does-not-exist");
960
961 storage.delete_object(&id).await.unwrap();
962 }
963
964 #[tokio::test]
967 async fn put_small_object_stores_inline() {
968 let (storage, hv, lt, _) = make_tiered_storage();
969 let id = make_id("small");
970 let payload = b"small payload".to_vec();
971
972 storage
973 .put_object(&id, &Metadata::default(), stream::single(payload.clone()))
974 .await
975 .unwrap();
976
977 assert!(hv.contains(&id), "expected in high-volume");
978 assert!(!lt.contains(&id), "leaked to long-term");
979
980 let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
981 let body = stream::read_to_vec(s).await.unwrap();
982 assert_eq!(body, payload);
983
984 assert!(
985 storage.get_metadata(&id).await.unwrap().is_some(),
986 "get_metadata should return metadata for inline objects"
987 );
988 }
989
990 #[tokio::test]
991 async fn put_large_object_creates_tombstone() {
992 let (storage, hv, lt, _) = make_tiered_storage();
993 let id = make_id("large");
994 let payload = vec![0xCDu8; 2 * 1024 * 1024]; let metadata_in = Metadata {
996 content_type: "image/png".into(),
997 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
998 origin: Some("10.0.0.1".into()),
999 ..Metadata::default()
1000 };
1001
1002 storage
1003 .put_object(&id, &metadata_in, stream::single(payload.clone()))
1004 .await
1005 .unwrap();
1006
1007 let tombstone = hv.get(&id).expect_tombstone();
1009 assert_eq!(tombstone.expiration_policy, metadata_in.expiration_policy);
1010 let lt_id = tombstone.target;
1011 assert!(
1012 lt_id.key().starts_with(id.key()),
1013 "tombstone target key should be a revision of the HV key, got: {}",
1014 lt_id.key()
1015 );
1016
1017 let (lt_meta, _) = lt.get(<_id).expect_object();
1019 assert_eq!(lt_meta.content_type, "image/png");
1020 assert_eq!(lt_meta.expiration_policy, metadata_in.expiration_policy);
1021
1022 let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
1024 let body = stream::read_to_vec(s).await.unwrap();
1025 assert_eq!(body, payload);
1026
1027 let metadata = storage.get_metadata(&id).await.unwrap().unwrap();
1029 assert_eq!(metadata.content_type, "image/png");
1030 }
1031
1032 #[tokio::test]
1035 async fn reinsert_small_over_large_swaps_to_inline() {
1036 let (storage, hv, lt, _) = make_tiered_storage();
1037 let id = make_id("reinsert-key");
1038
1039 let large_payload = vec![0xABu8; 2 * 1024 * 1024];
1041 storage
1042 .put_object(&id, &Metadata::default(), stream::single(large_payload))
1043 .await
1044 .unwrap();
1045
1046 let lt_id = hv.get(&id).expect_tombstone().target;
1047
1048 let small_payload = vec![0xCDu8; 100]; storage
1052 .put_object(&id, &Metadata::default(), stream::single(small_payload))
1053 .await
1054 .unwrap();
1055
1056 hv.get(&id).expect_object();
1058
1059 storage.join().await;
1061
1062 lt.get(<_id).expect_not_found();
1064 }
1065
1066 #[tokio::test]
1067 async fn overwrite_large_with_large_replaces_revision() {
1068 let (storage, hv, lt, _) = make_tiered_storage();
1069 let id = make_id("overwrite-large");
1070
1071 let payload1 = vec![0xAAu8; 2 * 1024 * 1024];
1072 storage
1073 .put_object(&id, &Metadata::default(), stream::single(payload1))
1074 .await
1075 .unwrap();
1076 let lt_id_1 = hv.get(&id).expect_tombstone().target;
1077
1078 let payload2 = vec![0xBBu8; 2 * 1024 * 1024];
1079 storage
1080 .put_object(&id, &Metadata::default(), stream::single(payload2.clone()))
1081 .await
1082 .unwrap();
1083 let lt_id_2 = hv.get(&id).expect_tombstone().target;
1084
1085 assert_ne!(
1086 lt_id_1, lt_id_2,
1087 "second write should create a new revision"
1088 );
1089
1090 storage.join().await;
1092
1093 lt.get(<_id_1).expect_not_found();
1094 lt.get(<_id_2).expect_object();
1095
1096 let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
1097 let body = stream::read_to_vec(s).await.unwrap();
1098 assert_eq!(body, payload2);
1099 }
1100
1101 #[tokio::test]
1104 async fn delete_small_object() {
1105 let (storage, hv, _lt, _) = make_tiered_storage();
1106 let id = make_id("delete-small");
1107
1108 storage
1109 .put_object(&id, &Metadata::default(), stream::single("tiny"))
1110 .await
1111 .unwrap();
1112
1113 storage.delete_object(&id).await.unwrap();
1114
1115 hv.get(&id).expect_not_found();
1116 assert!(storage.get_object(&id, None).await.unwrap().is_none());
1117 }
1118
1119 #[tokio::test]
1120 async fn delete_large_object_cleans_up_both_backends() {
1121 let (storage, hv, lt, _) = make_tiered_storage();
1122 let id = make_id("delete-both");
1123 let payload = vec![0u8; 2 * 1024 * 1024]; storage
1126 .put_object(&id, &Metadata::default(), stream::single(payload))
1127 .await
1128 .unwrap();
1129
1130 let lt_id = hv.get(&id).expect_tombstone().target;
1132
1133 storage.delete_object(&id).await.unwrap();
1134
1135 storage.join().await;
1137
1138 assert!(!hv.contains(&id), "tombstone not cleaned up");
1139 assert!(!lt.contains(<_id), "long-term object not cleaned up");
1140 }
1141
1142 #[derive(Debug)]
1143 struct FailDelete;
1144
1145 #[async_trait::async_trait]
1146 impl Hooks for FailDelete {
1147 async fn delete_object(
1148 &self,
1149 _inner: &InMemoryBackend,
1150 _id: &ObjectId,
1151 ) -> Result<DeleteResponse> {
1152 Err(Error::Io(std::io::Error::new(
1153 std::io::ErrorKind::ConnectionRefused,
1154 "simulated long-term delete failure",
1155 )))
1156 }
1157 }
1158
1159 #[tokio::test]
1163 async fn delete_succeeds_when_gcs_cleanup_fails() {
1164 let hv = InMemoryBackend::new("hv");
1165 let lt = TestBackend::new(FailDelete);
1166 let log = NoopChangeLog;
1167 let storage = TieredStorage::new(Box::new(hv.clone()), Box::new(lt), Box::new(log));
1168
1169 let id = make_id("fail-delete");
1170 let payload = vec![0xABu8; 2 * 1024 * 1024]; storage
1172 .put_object(&id, &Metadata::default(), stream::single(payload))
1173 .await
1174 .unwrap();
1175
1176 let result = storage.delete_object(&id).await;
1178 assert!(
1179 result.is_ok(),
1180 "delete should succeed despite GCS cleanup failure"
1181 );
1182
1183 hv.get(&id).expect_not_found();
1185
1186 assert!(
1188 storage.get_object(&id, None).await.unwrap().is_none(),
1189 "object should be unreachable after tombstone is deleted"
1190 );
1191 }
1192
1193 #[derive(Debug)]
1196 struct CasConflict;
1197
1198 #[async_trait::async_trait]
1199 impl Hooks for CasConflict {
1200 async fn compare_and_write(
1201 &self,
1202 _inner: &InMemoryBackend,
1203 _id: &ObjectId,
1204 _current: Option<&ObjectId>,
1205 _write: TieredWrite,
1206 ) -> Result<bool> {
1207 Ok(false) }
1209 }
1210
1211 #[tokio::test]
1215 async fn put_large_cas_conflict_cleans_up_new_blob() {
1216 let hv = TestBackend::new(CasConflict);
1217 let lt = InMemoryBackend::new("lt");
1218 let log = NoopChangeLog;
1219 let storage = TieredStorage::new(Box::new(hv), Box::new(lt.clone()), Box::new(log));
1220
1221 let id = make_id("cas-conflict-large");
1222 let payload = vec![0xABu8; 2 * 1024 * 1024]; storage
1225 .put_object(&id, &Metadata::default(), stream::single(payload))
1226 .await
1227 .unwrap();
1228
1229 storage.join().await;
1231
1232 assert!(
1233 lt.is_empty(),
1234 "LT blob should be cleaned up after CAS conflict"
1235 );
1236 }
1237
1238 #[tokio::test]
1242 async fn put_small_over_tombstone_cas_conflict_succeeds() {
1243 let inner = InMemoryBackend::new("hv");
1244 let id = make_id("cas-conflict-small");
1245
1246 let tombstone = Tombstone {
1249 target: make_id("lt-object"),
1250 expiration_policy: ExpirationPolicy::Manual,
1251 };
1252 inner
1253 .compare_and_write(&id, None, TieredWrite::Tombstone(tombstone))
1254 .await
1255 .unwrap();
1256
1257 let lt = InMemoryBackend::new("lt");
1258 let hv = TestBackend::with_inner(inner, CasConflict);
1259 let log = NoopChangeLog;
1260 let storage = TieredStorage::new(Box::new(hv), Box::new(lt), Box::new(log));
1261
1262 storage
1265 .put_object(&id, &Metadata::default(), stream::single("tiny"))
1266 .await
1267 .unwrap();
1268 }
1269
1270 #[derive(Debug)]
1274 struct FailCas(bool);
1275
1276 #[async_trait::async_trait]
1277 impl Hooks for FailCas {
1278 async fn compare_and_write(
1279 &self,
1280 inner: &InMemoryBackend,
1281 id: &ObjectId,
1282 current: Option<&ObjectId>,
1283 write: TieredWrite,
1284 ) -> Result<bool> {
1285 if self.0 {
1286 inner.compare_and_write(id, current, write).await?;
1288 }
1289 Err(Error::Io(std::io::Error::new(
1290 std::io::ErrorKind::TimedOut,
1291 "simulated compare_and_write failure",
1292 )))
1293 }
1294 }
1295
1296 #[tokio::test]
1300 async fn no_orphan_when_tombstone_write_fails() {
1301 let lt = InMemoryBackend::new("lt");
1302 let hv = TestBackend::new(FailCas(false));
1303 let log = NoopChangeLog;
1304 let storage = TieredStorage::new(Box::new(hv), Box::new(lt.clone()), Box::new(log));
1305
1306 let id = make_id("orphan-test");
1307 let payload = vec![0xABu8; 2 * 1024 * 1024]; let result = storage
1309 .put_object(&id, &Metadata::default(), stream::single(payload))
1310 .await;
1311
1312 assert!(result.is_err());
1313
1314 storage.join().await;
1316
1317 assert!(lt.is_empty(), "long-term object not cleaned up");
1318 }
1319
1320 #[tokio::test]
1324 async fn orphan_tombstone_returns_none() {
1325 let (storage, hv, lt, _) = make_tiered_storage();
1326 let id = make_id("orphan-tombstone");
1327 let payload = vec![0xCDu8; 2 * 1024 * 1024]; storage
1330 .put_object(&id, &Metadata::default(), stream::single(payload))
1331 .await
1332 .unwrap();
1333
1334 let lt_id = hv.get(&id).expect_tombstone().target;
1336
1337 lt.remove(<_id);
1339
1340 assert!(
1341 storage.get_object(&id, None).await.unwrap().is_none(),
1342 "orphan tombstone should resolve to None on get_object"
1343 );
1344 assert!(
1345 storage.get_metadata(&id).await.unwrap().is_none(),
1346 "orphan tombstone should resolve to None on get_metadata"
1347 );
1348 }
1349
1350 #[tokio::test]
1355 async fn tombstone_target_is_used_for_reads_and_deletes() {
1356 let hv = InMemoryBackend::new("hv");
1357 let lt = InMemoryBackend::new("lt");
1358 let log = NoopChangeLog;
1359 let storage = TieredStorage::new(Box::new(hv.clone()), Box::new(lt.clone()), Box::new(log));
1360
1361 let hv_id = make_id("hv-key");
1362 let lt_id = make_id("lt-key");
1363 let payload = vec![0xABu8; 100];
1364
1365 lt.put_object(
1367 <_id,
1368 &Metadata::default(),
1369 stream::single(payload.clone()),
1370 )
1371 .await
1372 .unwrap();
1373 let tombstone = Tombstone {
1374 target: lt_id.clone(),
1375 expiration_policy: ExpirationPolicy::Manual,
1376 };
1377 hv.compare_and_write(&hv_id, None, TieredWrite::Tombstone(tombstone))
1378 .await
1379 .unwrap();
1380
1381 let (_, _, s) = storage.get_object(&hv_id, None).await.unwrap().unwrap();
1383 let body = stream::read_to_vec(s).await.unwrap();
1384 assert_eq!(body, payload);
1385
1386 storage.delete_object(&hv_id).await.unwrap();
1388 storage.join().await;
1389 assert!(!hv.contains(&hv_id), "tombstone should be removed");
1390 assert!(!lt.contains(<_id), "lt object should be removed");
1391 }
1392
1393 #[tokio::test]
1396 async fn multi_chunk_large_object_chains_buffered_and_remaining() {
1397 let (storage, hv, lt, _) = make_tiered_storage();
1398 let id = make_id("multi-chunk");
1399
1400 let chunk_size = 512 * 1024; let chunk_count = 4; let stream: ClientStream = futures_util::stream::iter(
1405 (0..chunk_count).map(move |i| Ok(Bytes::from(vec![i as u8; chunk_size]))),
1406 )
1407 .boxed();
1408
1409 storage
1410 .put_object(&id, &Metadata::default(), stream)
1411 .await
1412 .unwrap();
1413
1414 let lt_id = hv.get(&id).expect_tombstone().target;
1416 let (_, lt_bytes) = lt.get(<_id).expect_object();
1417 assert_eq!(lt_bytes.len(), chunk_size * chunk_count);
1418
1419 for i in 0..chunk_count {
1421 let offset = i * chunk_size;
1422 assert!(
1423 lt_bytes[offset..offset + chunk_size]
1424 .iter()
1425 .all(|&b| b == i as u8),
1426 "data mismatch in chunk {i}"
1427 );
1428 }
1429 }
1430
1431 #[tokio::test]
1437 async fn written_cleanup_after_lost_cas_response() {
1438 let (storage, hv, lt, log) = make_tiered_storage();
1439 let id = make_id("obj");
1440
1441 let payload = vec![0xAAu8; 2 * 1024 * 1024];
1443 storage
1444 .put_object(&id, &Metadata::default(), stream::single(payload.clone()))
1445 .await
1446 .unwrap();
1447 let tombstone1 = hv.get(&id).expect_tombstone().target;
1448
1449 let broken_storage = TieredStorage::new(
1451 Box::new(TestBackend::with_inner(hv.clone(), FailCas(true))),
1452 Box::new(lt.clone()),
1453 Box::new(log.clone()),
1454 );
1455 broken_storage
1456 .put_object(&id, &Metadata::default(), stream::single(payload.clone()))
1457 .await
1458 .unwrap_err(); let tombstone2 = hv.get(&id).expect_tombstone().target;
1460 assert_ne!(tombstone1, tombstone2);
1461
1462 broken_storage.join().await;
1464 lt.get(&tombstone1).expect_not_found();
1465 lt.get(&tombstone2).expect_object();
1466
1467 broken_storage.delete_object(&id).await.unwrap_err();
1469 hv.get(&id).expect_not_found();
1470 broken_storage.join().await;
1471 lt.get(&tombstone2).expect_not_found();
1472
1473 let id = make_id("obj2");
1475 storage
1476 .put_object(&id, &Metadata::default(), stream::single(payload.clone()))
1477 .await
1478 .unwrap();
1479 let tombstone3 = hv.get(&id).expect_tombstone().target;
1480
1481 broken_storage
1483 .put_object(&id, &Metadata::default(), stream::single(&b"small"[..]))
1484 .await
1485 .unwrap_err(); hv.get(&id).expect_object();
1487 broken_storage.join().await;
1488 lt.get(&tombstone3).expect_not_found();
1489 }
1490
1491 #[test]
1495 fn guard_dropped_outside_runtime_does_not_panic() {
1496 let manager = ChangeManager::new(
1497 Box::new(InMemoryBackend::new("hv")),
1498 Box::new(InMemoryBackend::new("lt")),
1499 Box::new(NoopChangeLog),
1500 );
1501
1502 let change = Change {
1503 id: make_id("object-key"),
1504 new: Some(make_id("cleanup-target")),
1505 old: None,
1506 cleanup_after: None,
1507 };
1508
1509 let guard = {
1512 let rt = tokio::runtime::Runtime::new().unwrap();
1513 rt.block_on(manager.record(change)).unwrap()
1514 };
1515
1516 drop(guard); }
1518
1519 #[tokio::test(start_paused = true)]
1524 async fn join_waits_for_cleanup_to_complete() {
1525 let (storage, _hv, _lt, _) = make_tiered_storage();
1526 let change = Change {
1527 id: make_id("object-key"),
1528 new: None,
1529 old: None,
1530 cleanup_after: None,
1531 };
1532 let mut guard = storage.record_change(change).await.unwrap();
1533
1534 tokio::spawn(async move {
1535 tokio::time::sleep(Duration::from_secs(10)).await;
1536 guard.advance(ChangePhase::Completed);
1537 drop(guard);
1538 });
1539
1540 let join_future = tokio::spawn(async move { storage.join().await });
1541
1542 tokio::time::sleep(Duration::from_secs(9)).await;
1543 assert!(!join_future.is_finished(), "finished before guard dropped");
1544
1545 tokio::time::sleep(Duration::from_secs(2)).await;
1546 assert!(join_future.is_finished(), "finish after guard drops");
1547 }
1548
1549 #[derive(Clone, Debug)]
1556 struct PauseAfterPut {
1557 paused: Arc<tokio::sync::Notify>,
1558 resume: Arc<tokio::sync::Notify>,
1559 }
1560
1561 #[async_trait::async_trait]
1562 impl Hooks for PauseAfterPut {
1563 async fn put_object(
1564 &self,
1565 inner: &InMemoryBackend,
1566 id: &ObjectId,
1567 metadata: &Metadata,
1568 stream: ClientStream,
1569 ) -> Result<PutResponse> {
1570 inner.put_object(id, metadata, stream).await?;
1571 self.paused.notify_one();
1572 self.resume.notified().await;
1573 Ok(())
1574 }
1575 }
1576
1577 #[tokio::test]
1580 async fn dropped_future_triggers_cleanup_and_log_entry_removed() {
1581 let paused = Arc::new(tokio::sync::Notify::new());
1582 let hooks = PauseAfterPut {
1583 paused: Arc::clone(&paused),
1584 resume: Arc::new(tokio::sync::Notify::new()),
1585 };
1586
1587 let lt_inner = InMemoryBackend::new("lt");
1588 let log = InMemoryChangeLog::default();
1589 let storage = TieredStorage::new(
1590 Box::new(InMemoryBackend::new("hv")),
1591 Box::new(TestBackend::with_inner(lt_inner.clone(), hooks)),
1592 Box::new(log.clone()),
1593 );
1594
1595 let id = make_id("drop-test");
1596 let metadata = Metadata::default();
1597 let payload = vec![0xABu8; 2 * 1024 * 1024]; tokio::select! {
1601 result = storage.put_object(&id, &metadata, stream::single(payload)) => {
1602 panic!("expected put to pause before completing, got: {result:?}");
1603 }
1604 _ = paused.notified() => {
1605 }
1607 }
1608
1609 storage.join().await;
1611
1612 assert!(lt_inner.is_empty(), "orphaned LT blob was not cleaned up");
1614
1615 let entries = log.scan().await.unwrap();
1617 assert!(
1618 entries.is_empty(),
1619 "changelog entry not removed after cleanup"
1620 );
1621 }
1622
1623 #[test]
1626 fn multipart_upload_id_roundtrip() {
1627 let id = TieredUploadId {
1628 revision: "my-key/01924a6f-7e28-7b9a-9c1d-abcdef123456".into(),
1629 upload_id: UploadId::new("upstream-upload-id-abc".into()).unwrap(),
1630 };
1631 let encoded: UploadId = id.clone().try_into().unwrap();
1632 let decoded: TieredUploadId = (&encoded.clone()).try_into().unwrap();
1633 assert_eq!(decoded, id);
1634 }
1635
1636 #[tokio::test]
1637 async fn multipart_single_part_roundtrip() {
1638 let (storage, hv, lt, _) = make_tiered_storage();
1639 let id = make_id("mp-single");
1640 let metadata = Metadata {
1641 content_type: "application/octet-stream".into(),
1642 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1643 ..Metadata::default()
1644 };
1645 let payload = vec![0xABu8; 2 * 1024 * 1024]; let upload_id = storage.initiate_multipart(&id, &metadata).await.unwrap();
1648
1649 let etag = storage
1650 .upload_part(
1651 &id,
1652 &upload_id,
1653 NonZeroU32::new(1).unwrap(),
1654 payload.len() as u64,
1655 None,
1656 stream::single(payload.clone()),
1657 )
1658 .await
1659 .unwrap();
1660
1661 let error = storage
1662 .complete_multipart(
1663 &id,
1664 &upload_id,
1665 vec![CompletedPart {
1666 part_number: NonZeroU32::new(1).unwrap(),
1667 etag,
1668 }],
1669 )
1670 .await
1671 .unwrap();
1672 assert!(
1673 error.is_none(),
1674 "complete_multipart returned error: {error:?}"
1675 );
1676
1677 let (got_meta, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
1679 let body = stream::read_to_vec(s).await.unwrap();
1680 assert_eq!(body, payload);
1681 assert_eq!(got_meta.content_type, "application/octet-stream");
1682
1683 let tombstone = hv.get(&id).expect_tombstone();
1685 assert!(
1686 tombstone.target.key().starts_with(id.key()),
1687 "tombstone target should be a revision key"
1688 );
1689 lt.get(&tombstone.target).expect_object();
1690 }
1691
1692 #[tokio::test]
1693 async fn multipart_upload() {
1694 let (storage, _hv, _lt, _) = make_tiered_storage();
1695 let id = make_id("multipart");
1696
1697 let upload_id = storage
1698 .initiate_multipart(&id, &Metadata::default())
1699 .await
1700 .unwrap();
1701
1702 let part1 = vec![0xAAu8; 512 * 1024];
1703 let part2 = vec![0xBBu8; 512 * 1024];
1704 let part3 = vec![0xCCu8; 512 * 1024];
1705
1706 let etag3 = storage
1707 .upload_part(
1708 &id,
1709 &upload_id,
1710 NonZeroU32::new(3).unwrap(),
1711 part3.len() as u64,
1712 None,
1713 stream::single(part3.clone()),
1714 )
1715 .await
1716 .unwrap();
1717 let etag2 = storage
1718 .upload_part(
1719 &id,
1720 &upload_id,
1721 NonZeroU32::new(2).unwrap(),
1722 part2.len() as u64,
1723 None,
1724 stream::single(part2.clone()),
1725 )
1726 .await
1727 .unwrap();
1728 let etag1 = storage
1729 .upload_part(
1730 &id,
1731 &upload_id,
1732 NonZeroU32::new(1).unwrap(),
1733 part1.len() as u64,
1734 None,
1735 stream::single(part1.clone()),
1736 )
1737 .await
1738 .unwrap();
1739
1740 let error = storage
1741 .complete_multipart(
1742 &id,
1743 &upload_id,
1744 vec![
1745 CompletedPart {
1746 part_number: NonZeroU32::new(1).unwrap(),
1747 etag: etag1,
1748 },
1749 CompletedPart {
1750 part_number: NonZeroU32::new(2).unwrap(),
1751 etag: etag2,
1752 },
1753 CompletedPart {
1754 part_number: NonZeroU32::new(3).unwrap(),
1755 etag: etag3,
1756 },
1757 ],
1758 )
1759 .await
1760 .unwrap();
1761 assert!(error.is_none());
1762
1763 let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
1764 let body = stream::read_to_vec(s).await.unwrap();
1765
1766 let mut expected = Vec::new();
1767 expected.extend_from_slice(&part1);
1768 expected.extend_from_slice(&part2);
1769 expected.extend_from_slice(&part3);
1770 assert_eq!(body, expected);
1771 }
1772
1773 #[tokio::test]
1774 async fn multipart_abort() {
1775 let (storage, hv, _lt, _) = make_tiered_storage();
1776 let id = make_id("mp-abort");
1777
1778 let upload_id = storage
1779 .initiate_multipart(&id, &Metadata::default())
1780 .await
1781 .unwrap();
1782
1783 let payload = vec![0xABu8; 100];
1785 storage
1786 .upload_part(
1787 &id,
1788 &upload_id,
1789 NonZeroU32::new(1).unwrap(),
1790 payload.len() as u64,
1791 None,
1792 stream::single(payload),
1793 )
1794 .await
1795 .unwrap();
1796
1797 storage.abort_multipart(&id, &upload_id).await.unwrap();
1798
1799 hv.get(&id).expect_not_found();
1801
1802 assert!(storage.get_object(&id, None).await.unwrap().is_none());
1804 }
1805
1806 #[tokio::test]
1807 async fn multipart_list_parts() {
1808 let (storage, _hv, _lt, _) = make_tiered_storage();
1809 let id = make_id("mp-list");
1810
1811 let upload_id = storage
1812 .initiate_multipart(&id, &Metadata::default())
1813 .await
1814 .unwrap();
1815
1816 let part1 = vec![0xAAu8; 100];
1817 let part2 = vec![0xBBu8; 200];
1818 storage
1819 .upload_part(
1820 &id,
1821 &upload_id,
1822 NonZeroU32::new(1).unwrap(),
1823 part1.len() as u64,
1824 None,
1825 stream::single(part1),
1826 )
1827 .await
1828 .unwrap();
1829 storage
1830 .upload_part(
1831 &id,
1832 &upload_id,
1833 NonZeroU32::new(2).unwrap(),
1834 part2.len() as u64,
1835 None,
1836 stream::single(part2),
1837 )
1838 .await
1839 .unwrap();
1840
1841 let resp = storage
1842 .list_parts(&id, &upload_id, None, None)
1843 .await
1844 .unwrap();
1845 assert_eq!(resp.parts.len(), 2);
1846 assert_eq!(resp.parts[0].part_number.get(), 1);
1847 assert_eq!(resp.parts[0].size, 100);
1848 assert_eq!(resp.parts[1].part_number.get(), 2);
1849 assert_eq!(resp.parts[1].size, 200);
1850 }
1851
1852 #[tokio::test]
1853 async fn multipart_overwrites_existing_tombstone() {
1854 let (storage, hv, lt, _) = make_tiered_storage();
1855 let id = make_id("mp-overwrite");
1856
1857 let payload1 = vec![0xAAu8; 2 * 1024 * 1024];
1859 storage
1860 .put_object(&id, &Metadata::default(), stream::single(payload1))
1861 .await
1862 .unwrap();
1863 let old_lt_id = hv.get(&id).expect_tombstone().target;
1864
1865 let upload_id = storage
1867 .initiate_multipart(&id, &Metadata::default())
1868 .await
1869 .unwrap();
1870
1871 let payload2 = vec![0xBBu8; 2 * 1024 * 1024];
1872 let etag = storage
1873 .upload_part(
1874 &id,
1875 &upload_id,
1876 NonZeroU32::new(1).unwrap(),
1877 payload2.len() as u64,
1878 None,
1879 stream::single(payload2.clone()),
1880 )
1881 .await
1882 .unwrap();
1883
1884 let lt_id = hv.get(&id).expect_tombstone().target;
1887 assert_eq!(old_lt_id, lt_id);
1888
1889 let error = storage
1890 .complete_multipart(
1891 &id,
1892 &upload_id,
1893 vec![CompletedPart {
1894 part_number: NonZeroU32::new(1).unwrap(),
1895 etag,
1896 }],
1897 )
1898 .await
1899 .unwrap();
1900 assert!(error.is_none());
1901
1902 let new_lt_id = hv.get(&id).expect_tombstone().target;
1904 assert_ne!(old_lt_id, new_lt_id);
1905
1906 storage.join().await;
1908
1909 lt.get(&old_lt_id).expect_not_found();
1911 lt.get(&new_lt_id).expect_object();
1912
1913 let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
1915 let body = stream::read_to_vec(s).await.unwrap();
1916 assert_eq!(body, payload2);
1917 }
1918
1919 #[derive(Debug)]
1925 struct CompleteMultipartButReturnError;
1926
1927 #[async_trait::async_trait]
1928 impl Hooks for CompleteMultipartButReturnError {
1929 async fn complete_multipart(
1930 &self,
1931 inner: &InMemoryBackend,
1932 id: &ObjectId,
1933 upload_id: &UploadId,
1934 parts: Vec<CompletedPart>,
1935 ) -> Result<CompleteMultipartResponse> {
1936 inner
1937 .complete_multipart(id, upload_id, parts)
1938 .await
1939 .unwrap();
1940 Err(Error::Io(std::io::Error::new(
1941 std::io::ErrorKind::TimedOut,
1942 "simulated network error on complete_multipart",
1943 )))
1944 }
1945
1946 async fn get_metadata(
1947 &self,
1948 _inner: &InMemoryBackend,
1949 _id: &ObjectId,
1950 ) -> Result<MetadataResponse> {
1951 Err(Error::Io(std::io::Error::new(
1952 std::io::ErrorKind::TimedOut,
1953 "simulated network error on get_metadata",
1954 )))
1955 }
1956 }
1957
1958 #[tokio::test]
1962 async fn cleans_up_orphan_after_failed_multipart_complete() {
1963 let hv = InMemoryBackend::new("hv");
1964 let lt_inner = InMemoryBackend::new("lt");
1965 let log = InMemoryChangeLog::default();
1966 let storage = TieredStorage::new(
1967 Box::new(hv.clone()),
1968 Box::new(TestBackend::with_inner(
1969 lt_inner.clone(),
1970 CompleteMultipartButReturnError {},
1971 )),
1972 Box::new(log.clone()),
1973 );
1974
1975 let id = make_id("mp-orphan");
1976 let upload_id = storage
1977 .initiate_multipart(&id, &Metadata::default())
1978 .await
1979 .unwrap();
1980
1981 let tiered_id: TieredUploadId = (&upload_id).try_into().unwrap();
1982 let physical = ObjectId {
1983 context: id.context.clone(),
1984 key: tiered_id.revision,
1985 };
1986
1987 let payload = vec![0xABu8; 2 * 1024 * 1024];
1988 let etag = storage
1989 .upload_part(
1990 &id,
1991 &upload_id,
1992 NonZeroU32::new(1).unwrap(),
1993 payload.len() as u64,
1994 None,
1995 stream::single(payload),
1996 )
1997 .await
1998 .unwrap();
1999
2000 let result = storage
2001 .complete_multipart(
2002 &id,
2003 &upload_id,
2004 vec![CompletedPart {
2005 part_number: NonZeroU32::new(1).unwrap(),
2006 etag,
2007 }],
2008 )
2009 .await;
2010 assert!(result.is_err());
2011 storage.join().await;
2012
2013 lt_inner.get(&physical).expect_object();
2016 hv.get(&id).expect_not_found();
2017
2018 log.expire_all();
2020 let manager = ChangeManager::new(
2021 Box::new(hv.clone()),
2022 Box::new(lt_inner.clone()),
2023 Box::new(log.clone()),
2024 );
2025 manager.recover().await.unwrap();
2026
2027 lt_inner.get(&physical).expect_not_found();
2029 let remaining = log.scan().await.unwrap();
2031 assert!(remaining.is_empty());
2032 }
2033
2034 #[derive(Debug)]
2035 struct FailOnFirstCompleteMultipartAttempt {
2036 attempt: Mutex<u32>,
2037 }
2038
2039 impl FailOnFirstCompleteMultipartAttempt {
2040 fn new() -> Self {
2041 Self {
2042 attempt: Mutex::new(0),
2043 }
2044 }
2045 }
2046
2047 #[async_trait::async_trait]
2048 impl Hooks for FailOnFirstCompleteMultipartAttempt {
2049 async fn complete_multipart(
2050 &self,
2051 inner: &InMemoryBackend,
2052 id: &ObjectId,
2053 upload_id: &UploadId,
2054 parts: Vec<CompletedPart>,
2055 ) -> Result<CompleteMultipartResponse> {
2056 let mut attempt = self.attempt.lock().await;
2057 *attempt += 1;
2058 if *attempt == 1 {
2059 Err(Error::Io(std::io::Error::new(
2060 std::io::ErrorKind::TimedOut,
2061 "simulated network error",
2062 )))
2063 } else {
2064 Ok(inner
2065 .complete_multipart(id, upload_id, parts)
2066 .await
2067 .unwrap())
2068 }
2069 }
2070 }
2071
2072 #[tokio::test]
2077 async fn multipart_complete_succeeds_on_retry_and_leaves_state_consistent() {
2078 let hv = InMemoryBackend::new("hv");
2079 let lt_inner = InMemoryBackend::new("lt");
2080 let log = InMemoryChangeLog::default();
2081 let storage = TieredStorage::new(
2082 Box::new(hv.clone()),
2083 Box::new(TestBackend::with_inner(
2084 lt_inner.clone(),
2085 FailOnFirstCompleteMultipartAttempt::new(),
2086 )),
2087 Box::new(log.clone()),
2088 );
2089
2090 let id = make_id("mp-retry");
2091 let upload_id = storage
2092 .initiate_multipart(&id, &Metadata::default())
2093 .await
2094 .unwrap();
2095
2096 let tiered_id: TieredUploadId = (&upload_id).try_into().unwrap();
2097 let physical = ObjectId {
2098 context: id.context.clone(),
2099 key: tiered_id.revision,
2100 };
2101
2102 let payload = vec![0xABu8; 2 * 1024 * 1024];
2103 let etag = storage
2104 .upload_part(
2105 &id,
2106 &upload_id,
2107 NonZeroU32::new(1).unwrap(),
2108 payload.len() as u64,
2109 None,
2110 stream::single(payload.clone()),
2111 )
2112 .await
2113 .unwrap();
2114
2115 let result = storage
2117 .complete_multipart(
2118 &id,
2119 &upload_id,
2120 vec![CompletedPart {
2121 part_number: NonZeroU32::new(1).unwrap(),
2122 etag: etag.clone(),
2123 }],
2124 )
2125 .await;
2126 assert!(result.is_err());
2127 storage.join().await;
2128
2129 let result = storage
2131 .complete_multipart(
2132 &id,
2133 &upload_id,
2134 vec![CompletedPart {
2135 part_number: NonZeroU32::new(1).unwrap(),
2136 etag,
2137 }],
2138 )
2139 .await;
2140 assert!(result.is_ok());
2141 storage.join().await;
2142
2143 let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
2145 let body = stream::read_to_vec(s).await.unwrap();
2146 assert_eq!(body, payload);
2147
2148 log.expire_all();
2150 let manager = ChangeManager::new(
2151 Box::new(hv.clone()),
2152 Box::new(lt_inner.clone()),
2153 Box::new(log.clone()),
2154 );
2155 manager.recover().await.unwrap();
2156
2157 lt_inner.get(&physical).expect_object();
2159 let tombstone = hv.get(&id).expect_tombstone();
2161 assert_eq!(tombstone.target, physical);
2162 let remaining = log.scan().await.unwrap();
2164 assert!(remaining.is_empty());
2165
2166 let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
2168 let body = stream::read_to_vec(s).await.unwrap();
2169 assert_eq!(body, payload);
2170 }
2171
2172 #[derive(Debug)]
2173 struct FailOnFirstGetMetadataAttempt {
2174 attempt: Mutex<u32>,
2175 }
2176
2177 impl FailOnFirstGetMetadataAttempt {
2178 fn new() -> Self {
2179 Self {
2180 attempt: Mutex::new(0),
2181 }
2182 }
2183 }
2184
2185 #[async_trait::async_trait]
2186 impl Hooks for FailOnFirstGetMetadataAttempt {
2187 async fn get_metadata(
2188 &self,
2189 inner: &InMemoryBackend,
2190 id: &ObjectId,
2191 ) -> Result<MetadataResponse> {
2192 let mut attempt = self.attempt.lock().await;
2193 *attempt += 1;
2194 if *attempt == 1 {
2195 Err(Error::Io(std::io::Error::new(
2196 std::io::ErrorKind::TimedOut,
2197 "simulated network error",
2198 )))
2199 } else {
2200 inner.get_metadata(id).await
2201 }
2202 }
2203 }
2204
2205 #[tokio::test]
2212 async fn multipart_complete_succeeds_on_retry_if_get_metadata_errs_and_leaves_state_consistent()
2213 {
2214 let hv = InMemoryBackend::new("hv");
2215 let lt_inner = InMemoryBackend::new("lt");
2216 let log = InMemoryChangeLog::default();
2217 let storage = TieredStorage::new(
2218 Box::new(hv.clone()),
2219 Box::new(TestBackend::with_inner(
2220 lt_inner.clone(),
2221 FailOnFirstGetMetadataAttempt::new(),
2222 )),
2223 Box::new(log.clone()),
2224 );
2225
2226 let id = make_id("mp-retry-meta");
2227 let upload_id = storage
2228 .initiate_multipart(&id, &Metadata::default())
2229 .await
2230 .unwrap();
2231
2232 let tiered_id: TieredUploadId = (&upload_id).try_into().unwrap();
2233 let physical = ObjectId {
2234 context: id.context.clone(),
2235 key: tiered_id.revision,
2236 };
2237
2238 let payload = vec![0xABu8; 2 * 1024 * 1024];
2239 let etag = storage
2240 .upload_part(
2241 &id,
2242 &upload_id,
2243 NonZeroU32::new(1).unwrap(),
2244 payload.len() as u64,
2245 None,
2246 stream::single(payload.clone()),
2247 )
2248 .await
2249 .unwrap();
2250
2251 let result = storage
2254 .complete_multipart(
2255 &id,
2256 &upload_id,
2257 vec![CompletedPart {
2258 part_number: NonZeroU32::new(1).unwrap(),
2259 etag: etag.clone(),
2260 }],
2261 )
2262 .await;
2263 assert!(result.is_err());
2264 storage.join().await;
2265
2266 let result = storage
2268 .complete_multipart(
2269 &id,
2270 &upload_id,
2271 vec![CompletedPart {
2272 part_number: NonZeroU32::new(1).unwrap(),
2273 etag,
2274 }],
2275 )
2276 .await;
2277 assert!(result.is_ok());
2278 storage.join().await;
2279
2280 let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
2282 let body = stream::read_to_vec(s).await.unwrap();
2283 assert_eq!(body, payload);
2284
2285 log.expire_all();
2287 let manager = ChangeManager::new(
2288 Box::new(hv.clone()),
2289 Box::new(lt_inner.clone()),
2290 Box::new(log.clone()),
2291 );
2292 manager.recover().await.unwrap();
2293
2294 lt_inner.get(&physical).expect_object();
2296 let tombstone = hv.get(&id).expect_tombstone();
2298 assert_eq!(tombstone.target, physical);
2299 let remaining = log.scan().await.unwrap();
2301 assert!(remaining.is_empty());
2302
2303 let (_, _, s) = storage.get_object(&id, None).await.unwrap().unwrap();
2305 let body = stream::read_to_vec(s).await.unwrap();
2306 assert_eq!(body, payload);
2307 }
2308}