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