1use std::num::NonZeroU64;
112use std::sync::Arc;
113use std::sync::atomic::Ordering;
114use std::time::Duration;
115
116use bytes::Bytes;
117use futures_util::StreamExt;
118use objectstore_metrics::TimerGuard;
119use objectstore_types::metadata::Metadata;
120use objectstore_types::range::ByteRange;
121use objectstore_types::resumable::UploadProgress;
122use objectstore_types::time::Timestamp;
123use sentry::{Hub, SentryFutureExt};
124use serde::{Deserialize, Serialize};
125
126use crate::backend::changelog::{Change, ChangeGuard, ChangeLog, ChangeManager, ChangePhase};
127use crate::backend::common::{
128 Backend, DeleteResponse, ExpiryTarget, ExpiryUpdate, GetResponse, HighVolumeBackend,
129 MetadataResponse, PutResponse, SetExpiryResponse, TieredGet, TieredMetadata, TieredUpdate,
130 TieredWrite, Tombstone,
131};
132use crate::backend::{HighVolumeStorageConfig, StorageConfig};
133use crate::error::{ErrorKind, Result, ResultExt as _};
134use crate::id::ObjectId;
135use crate::resumable::{BackendToken, Session};
136use crate::stream::{ClientStream, SizedPeek, counting_stream};
137
138const BACKEND_SIZE_THRESHOLD: usize = 1024 * 1024; const RESUMABLE_UPLOAD_TTL: Duration = Duration::from_hours(5 * 24);
143
144fn new_long_term_revision(id: &ObjectId) -> ObjectId {
150 ObjectId {
151 context: id.context.clone(),
152 key: format!("{}/{}", id.key, uuid::Uuid::now_v7()),
153 }
154}
155
156#[derive(Debug, Clone, Deserialize, Serialize)]
177pub struct TieredStorageConfig {
178 pub high_volume: HighVolumeStorageConfig,
183 pub long_term: Box<StorageConfig>,
185}
186
187#[derive(Debug)]
238pub struct TieredStorage {
239 inner: Arc<ChangeManager>,
240}
241
242impl TieredStorage {
243 pub fn new(
245 high_volume: Box<dyn HighVolumeBackend>,
246 long_term: Box<dyn Backend>,
247 changelog: Box<dyn ChangeLog>,
248 ) -> Self {
249 let inner = ChangeManager::new(high_volume, long_term, changelog);
250 let hub = Hub::new_from_top(Hub::current());
251 tokio::spawn(inner.clone().recover().bind_hub(hub));
254 Self { inner }
255 }
256
257 async fn record_change(&self, change: Change) -> Result<ChangeGuard> {
259 self.inner.clone().record(change).await
260 }
261
262 async fn check_upload_marker(&self, revision: &ObjectId) -> Result<()> {
264 if !self
265 .inner
266 .high_volume
267 .has_upload_marker(revision, Timestamp::now())
268 .await?
269 {
270 return Err(ErrorKind::UploadSessionGone.into());
271 }
272 Ok(())
273 }
274
275 async fn delete_upload_marker(&self, revision: &ObjectId) -> Result<()> {
277 if !self
278 .inner
279 .high_volume
280 .delete_upload_marker(revision, Timestamp::now())
281 .await?
282 {
283 return Err(ErrorKind::UploadSessionGone.into());
284 }
285 Ok(())
286 }
287
288 fn backend_type(&self, choice: &BackendChoice) -> &'static str {
290 match choice {
291 BackendChoice::HighVolume => self.inner.high_volume.name(),
292 BackendChoice::LongTerm => self.inner.long_term.name(),
293 }
294 }
295
296 fn record_chunk_size(&self, id: &ObjectId, content_length: u64) {
298 objectstore_metrics::record!(
299 "put_chunk.size" = content_length,
300 usecase = id.usecase().to_string(),
301 backend_choice = BackendChoice::LongTerm.as_str(),
302 backend_type = self.backend_type(&BackendChoice::LongTerm),
303 );
304 }
305
306 fn tag_backend(&self, timer: &mut TimerGuard, choice: &BackendChoice) {
308 timer.set_tag("backend_choice", choice.as_str());
309 timer.set_tag("backend_type", self.backend_type(choice));
310 }
311
312 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
317 async fn put_high_volume(
318 &self,
319 id: &ObjectId,
320 metadata: &Metadata,
321 payload: Bytes,
322 access_time: Timestamp,
323 ) -> Result<()> {
324 let tombstone_opt = self
325 .inner
326 .high_volume
327 .put_non_tombstone(id, metadata, payload.clone(), access_time)
328 .await?;
329
330 let Some(Tombstone { target, .. }) = tombstone_opt else {
331 return Ok(());
333 };
334
335 let mut guard = self
337 .record_change(Change {
338 id: id.clone(),
339 new: None,
340 old: Some(target.clone()),
341 })
342 .await?;
343
344 let write = TieredWrite::Object(metadata.clone(), payload);
345 guard.advance(ChangePhase::Written);
346
347 let written = self
348 .inner
349 .high_volume
350 .compare_and_write(id, Some(&target), write, access_time)
351 .await?;
352
353 guard.advance(ChangePhase::compare_and_write(written));
355
356 Ok(())
357 }
358
359 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
364 async fn put_long_term(
365 &self,
366 id: &ObjectId,
367 metadata: &Metadata,
368 stream: ClientStream,
369 access_time: Timestamp,
370 ) -> Result<()> {
371 let current = match self
373 .inner
374 .high_volume
375 .get_tiered_metadata(id, access_time)
376 .await?
377 {
378 TieredMetadata::Tombstone(t) => Some(t.target),
379 _ => None,
380 };
381
382 let new = new_long_term_revision(id);
384 let mut guard = self
385 .record_change(Change {
386 id: id.clone(),
387 new: Some(new.clone()),
388 old: current.clone(),
389 })
390 .await?;
391
392 self.inner
393 .long_term
394 .put_object(&new, metadata, stream, access_time)
395 .await?;
396 guard.advance(ChangePhase::Written);
397
398 let tombstone = Tombstone {
400 target: new.clone(),
401 time_expires: metadata.time_expires,
402 };
403 let written = self
404 .inner
405 .high_volume
406 .compare_and_write(
407 id,
408 current.as_ref(),
409 TieredWrite::Tombstone(tombstone),
410 access_time,
411 )
412 .await?;
413
414 guard.advance(ChangePhase::compare_and_write(written));
416
417 Ok(())
418 }
419}
420
421type LongTermBackendToken = BackendToken;
422
423#[derive(Debug, Serialize, Deserialize)]
424struct TieredResumableToken {
425 inner: LongTermBackendToken,
426 revision: String,
427 time_expires: Option<Timestamp>,
428}
429
430impl TieredResumableToken {
431 fn decode(token: &BackendToken) -> Result<Self> {
432 serde_json::from_str(token).map_err(|_| ErrorKind::UnknownUploadSession.into())
433 }
434
435 fn into_inner_session(self, session: &Session) -> Session {
437 Session {
438 object_id: ObjectId {
439 context: session.object_id.context.clone(),
440 key: self.revision,
441 },
442 backend_token: self.inner,
443 ..session.clone()
444 }
445 }
446}
447
448#[async_trait::async_trait]
449impl Backend for TieredStorage {
450 fn name(&self) -> &'static str {
451 "tiered"
452 }
453
454 fn upload_granularity(&self) -> u64 {
455 self.inner.long_term.upload_granularity()
456 }
457
458 #[tracing::instrument(level = "debug", fields(?id, upload_length), skip_all)]
459 async fn create_upload_session(
460 &self,
461 id: &ObjectId,
462 metadata: &Metadata,
463 upload_length: NonZeroU64,
464 ) -> Result<Option<BackendToken>> {
465 if upload_length.get() <= BACKEND_SIZE_THRESHOLD as u64 {
466 return Ok(None);
467 }
468
469 let revision = new_long_term_revision(id);
470 let Some(inner) = self
471 .inner
472 .long_term
473 .create_upload_session(&revision, metadata, upload_length)
474 .await?
475 else {
476 return Ok(None);
477 };
478
479 if let Err(error) = self
480 .inner
481 .high_volume
482 .create_upload_marker(&revision, Timestamp::now() + RESUMABLE_UPLOAD_TTL)
483 .await
484 {
485 let session = Session {
487 object_id: revision,
488 upload_length,
489 backend_token: inner,
490 };
491 if let Err(cleanup_error) = self.inner.long_term.cancel_upload(&session).await {
492 objectstore_log::warn!(
493 !!&cleanup_error,
494 "Failed to cancel upload after marker creation failed"
495 );
496 }
497 return Err(error);
498 }
499 if metadata.origin.is_none() {
500 objectstore_metrics::count!("put.origin_missing", usecase = id.usecase().to_string());
501 }
502 let token = TieredResumableToken {
503 revision: revision.key,
504 inner,
505 time_expires: metadata.time_expires,
506 };
507 Ok(Some(serde_json::to_string(&token).context(
508 ErrorKind::Internal,
509 "encoding tiered resumable session",
510 )?))
511 }
512
513 #[tracing::instrument(level = "debug", fields(?session, offset, content_length), skip_all)]
514 async fn put_chunk(
515 &self,
516 session: &Session,
517 offset: u64,
518 content_length: u64,
519 stream: ClientStream,
520 ) -> Result<UploadProgress> {
521 let timer = objectstore_metrics::timer!(
522 "put_chunk.latency",
523 usecase = session.object_id.usecase().to_string(),
524 backend_choice = BackendChoice::LongTerm.as_str(),
525 backend_type = self.backend_type(&BackendChoice::LongTerm),
526 );
527 let tiered = TieredResumableToken::decode(&session.backend_token)?;
528 let time_expires = tiered.time_expires;
529 let inner_session = tiered.into_inner_session(session);
530 let id = &session.object_id;
531 let revision = &inner_session.object_id;
532 let end = offset
533 .checked_add(content_length)
534 .filter(|end| *end <= session.upload_length.get())
535 .ok_or(ErrorKind::ChunkExceedsUploadLength {
536 offset,
537 content_length,
538 upload_length: session.upload_length.get(),
539 })?;
540
541 let granularity = self.upload_granularity();
542 if content_length > 0 && content_length < granularity && end != session.upload_length.get()
543 {
544 return Err(ErrorKind::ChunkTooSmall {
545 chunk_length: content_length,
546 upload_granularity: granularity,
547 }
548 .into());
549 }
550
551 if end != session.upload_length.get() {
553 let progress = self
554 .inner
555 .long_term
556 .put_chunk(&inner_session, offset, content_length, stream)
557 .await?;
558 if progress == UploadProgress::Complete {
559 return Err(ErrorKind::UploadSessionGone.into());
560 }
561 timer.record();
562 self.record_chunk_size(id, content_length);
563 return Ok(progress);
564 }
565
566 let (progress, current) = tokio::join!(
568 self.inner.long_term.upload_offset(&inner_session),
569 self.inner
570 .high_volume
571 .get_tiered_metadata(id, Timestamp::now()),
572 );
573 match progress? {
574 UploadProgress::Complete => return Err(ErrorKind::UploadSessionGone.into()),
575 UploadProgress::Incomplete { offset: actual } if actual < offset => {
576 return Err(ErrorKind::UploadOffsetMismatch { offset: actual }.into());
577 }
578 UploadProgress::Incomplete { .. } => {}
579 }
580 let current = match current? {
581 TieredMetadata::Tombstone(t) if t.target == *revision => {
583 timer.record();
584 return Ok(UploadProgress::Complete);
585 }
586 TieredMetadata::Tombstone(t) => Some(t.target),
587 _ => None,
588 };
589
590 self.delete_upload_marker(revision).await?;
592
593 let mut guard = self
595 .record_change(Change {
596 id: id.clone(),
597 new: Some(revision.clone()),
598 old: current.clone(),
599 })
600 .await?;
601
602 let progress = self
603 .inner
604 .long_term
605 .put_chunk(&inner_session, offset, content_length, stream)
606 .await?;
607 if progress != UploadProgress::Complete {
608 return Err(ErrorKind::UploadSessionGone.into());
609 }
610 guard.advance(ChangePhase::Written);
611
612 let written = self
613 .inner
614 .high_volume
615 .compare_and_write(
616 id,
617 current.as_ref(),
618 TieredWrite::Tombstone(Tombstone {
619 target: revision.clone(),
620 time_expires,
621 }),
622 Timestamp::now(),
623 )
624 .await?;
625 guard.advance(ChangePhase::compare_and_write(written));
626
627 timer.record();
628 self.record_chunk_size(id, content_length);
629 objectstore_metrics::record!(
630 "put.size" = session.upload_length.get(),
631 usecase = id.usecase().to_string(),
632 backend_choice = BackendChoice::LongTerm.as_str(),
633 backend_type = self.backend_type(&BackendChoice::LongTerm),
634 upload_type = "resumable",
635 );
636 Ok(UploadProgress::Complete)
637 }
638
639 #[tracing::instrument(level = "debug", fields(?session), skip_all)]
640 async fn upload_offset(&self, session: &Session) -> Result<UploadProgress> {
641 let tiered = TieredResumableToken::decode(&session.backend_token)?;
642 let inner_session = tiered.into_inner_session(session);
643 self.check_upload_marker(&inner_session.object_id).await?;
644 match self.inner.long_term.upload_offset(&inner_session).await? {
645 UploadProgress::Incomplete { offset } => Ok(UploadProgress::Incomplete { offset }),
646 UploadProgress::Complete => Err(ErrorKind::UploadSessionGone.into()),
647 }
648 }
649
650 #[tracing::instrument(level = "debug", fields(?session), skip_all)]
651 async fn cancel_upload(&self, session: &Session) -> Result<()> {
652 let tiered = TieredResumableToken::decode(&session.backend_token)?;
653 let inner_session = tiered.into_inner_session(session);
654 self.delete_upload_marker(&inner_session.object_id).await?;
655 if let Err(error) = self.inner.long_term.cancel_upload(&inner_session).await {
656 objectstore_log::warn!(!!&error, "Failed to cancel upload after deleting marker");
657 }
658 Ok(())
659 }
660
661 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
662 async fn put_object(
663 &self,
664 id: &ObjectId,
665 metadata: &Metadata,
666 stream: ClientStream,
667 access_time: Timestamp,
668 ) -> Result<PutResponse> {
669 let mut timer =
670 objectstore_metrics::timer!("put.latency", usecase = id.usecase().to_string(),);
671 if metadata.origin.is_none() {
672 objectstore_metrics::count!("put.origin_missing", usecase = id.usecase().to_string());
673 }
674
675 let peeked = SizedPeek::new(stream, BACKEND_SIZE_THRESHOLD).await?;
676 objectstore_metrics::record!(
677 "put.first_chunk.latency" = timer.elapsed(),
678 usecase = id.usecase().to_string(),
679 complete = if peeked.is_exhausted() { "yes" } else { "no" },
680 );
681
682 let (backend_choice, stored_size) = if peeked.is_exhausted() {
683 self.tag_backend(&mut timer, &BackendChoice::HighVolume);
684 let payload = peeked.into_bytes().await?;
685 let payload_len = payload.len() as u64;
686 self.put_high_volume(id, metadata, payload, access_time)
687 .await?;
688 (BackendChoice::HighVolume, payload_len)
689 } else {
690 self.tag_backend(&mut timer, &BackendChoice::LongTerm);
691 let (stored_size, stream) = counting_stream(peeked.into_stream());
692 self.put_long_term(id, metadata, stream.boxed(), access_time)
693 .await?;
694 (BackendChoice::LongTerm, stored_size.load(Ordering::Acquire))
695 };
696
697 let backend_ty = self.backend_type(&backend_choice);
698 timer.record();
699 objectstore_metrics::record!(
700 "put.size" = stored_size,
701 usecase = id.usecase().to_string(),
702 backend_choice = backend_choice.as_str(),
703 backend_type = backend_ty,
704 upload_type = "direct",
705 );
706
707 Ok(())
708 }
709
710 #[tracing::instrument(level = "debug", skip(self))]
711 async fn get_object(
712 &self,
713 id: &ObjectId,
714 access_time: Timestamp,
715 range: Option<ByteRange>,
716 ) -> Result<GetResponse> {
717 let mut timer = objectstore_metrics::timer!(
718 "get.latency.pre-response",
719 usecase = id.usecase().to_string(),
720 );
721 self.tag_backend(&mut timer, &BackendChoice::HighVolume);
722
723 let hv_result = self
724 .inner
725 .high_volume
726 .get_tiered_object(id, access_time, range)
727 .await?;
728 let (result, backend_choice) = match hv_result {
729 TieredGet::NotFound => (None, BackendChoice::HighVolume),
730 TieredGet::Object(metadata, content_range, stream) => (
731 Some((metadata, content_range, stream)),
732 BackendChoice::HighVolume,
733 ),
734 TieredGet::Tombstone(tombstone) => {
735 self.tag_backend(&mut timer, &BackendChoice::LongTerm);
736 let result = self
737 .inner
738 .long_term
739 .get_object(&tombstone.target, access_time, range)
740 .await?
741 .map(|(meta, range, stream)| (align_expiry(meta, &tombstone), range, stream));
742 (result, BackendChoice::LongTerm)
743 }
744 };
745
746 let backend_type = self.backend_type(&backend_choice);
747 timer.record();
748
749 if let Some((ref metadata, ref content_range, _)) = result {
750 let size = content_range.map(|cr| cr.len() as usize).or(metadata.size);
751 if let Some(size) = size {
752 objectstore_metrics::record!(
753 "get.size" = size,
754 usecase = id.usecase().to_string(),
755 backend_choice = backend_choice.as_str(),
756 backend_type = backend_type,
757 );
758 }
759 }
760
761 Ok(result)
762 }
763
764 #[tracing::instrument(level = "debug", skip(self))]
765 async fn get_metadata(
766 &self,
767 id: &ObjectId,
768 access_time: Timestamp,
769 ) -> Result<MetadataResponse> {
770 let mut timer =
771 objectstore_metrics::timer!("head.latency", usecase = id.usecase().to_string());
772 self.tag_backend(&mut timer, &BackendChoice::HighVolume);
773
774 let hv_result = self
775 .inner
776 .high_volume
777 .get_tiered_metadata(id, access_time)
778 .await?;
779 let result = match hv_result {
780 TieredMetadata::NotFound => None,
781 TieredMetadata::Object(metadata) => Some(metadata),
782 TieredMetadata::Tombstone(tombstone) => {
783 self.tag_backend(&mut timer, &BackendChoice::LongTerm);
784 self.inner
785 .long_term
786 .get_metadata(&tombstone.target, access_time)
787 .await?
788 .map(|metadata| align_expiry(metadata, &tombstone))
789 }
790 };
791
792 timer.record();
793
794 Ok(result)
795 }
796
797 async fn set_expiry(
798 &self,
799 id: &ObjectId,
800 target: ExpiryUpdate,
801 access_time: Timestamp,
802 ) -> Result<SetExpiryResponse> {
803 match self
804 .inner
805 .high_volume
806 .get_tiered_metadata(id, access_time)
807 .await?
808 {
809 TieredMetadata::NotFound => Ok(SetExpiryResponse::NotFound),
810 TieredMetadata::Object(_) => {
811 self.inner
812 .high_volume
813 .compare_and_update(id, None, TieredUpdate::SetExpiry(target), access_time)
814 .await
815 }
816 TieredMetadata::Tombstone(tombstone) => {
817 let deadline = match self
820 .inner
821 .long_term
822 .set_expiry(&tombstone.target, target, access_time)
823 .await?
824 {
825 SetExpiryResponse::Satisfied(deadline) => deadline,
826 outcome @ (SetExpiryResponse::NotFound | SetExpiryResponse::Rejected) => {
827 return Ok(outcome);
828 }
829 };
830
831 let update = TieredUpdate::SetExpiry(ExpiryTarget::At(deadline).into());
833
834 self.inner
840 .high_volume
841 .compare_and_update(id, Some(&tombstone.target), update, access_time)
842 .await
843 }
844 }
845 }
846
847 #[tracing::instrument(level = "debug", skip(self))]
848 async fn delete_object(&self, id: &ObjectId, access_time: Timestamp) -> Result<DeleteResponse> {
849 let mut timer =
850 objectstore_metrics::timer!("delete.latency", usecase = id.usecase().to_string());
851 self.tag_backend(&mut timer, &BackendChoice::HighVolume);
852
853 if let Some(tombstone) = self
854 .inner
855 .high_volume
856 .delete_non_tombstone(id, access_time)
857 .await?
858 {
859 self.tag_backend(&mut timer, &BackendChoice::LongTerm);
860
861 let mut guard = self
862 .record_change(Change {
863 id: id.clone(),
864 new: None,
865 old: Some(tombstone.target.clone()),
866 })
867 .await?;
868 guard.advance(ChangePhase::Written);
869
870 let deleted = self
872 .inner
873 .high_volume
874 .compare_and_write(
875 id,
876 Some(&tombstone.target),
877 TieredWrite::Delete,
878 access_time,
879 )
880 .await?;
881
882 guard.advance(ChangePhase::compare_and_write(deleted));
884 }
885
886 timer.record();
887
888 Ok(())
889 }
890
891 async fn join(&self) {
892 self.inner.tracker.close();
893 tokio::join!(
894 self.inner.high_volume.join(),
895 self.inner.long_term.join(),
896 self.inner.tracker.wait()
897 );
898 }
899}
900
901#[derive(Debug)]
902enum BackendChoice {
903 HighVolume,
904 LongTerm,
905}
906
907impl BackendChoice {
908 fn as_str(&self) -> &'static str {
909 match self {
910 BackendChoice::HighVolume => "high-volume",
911 BackendChoice::LongTerm => "long-term",
912 }
913 }
914}
915
916impl std::fmt::Display for BackendChoice {
917 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
918 f.write_str(self.as_str())
919 }
920}
921
922fn effective_expiry(
926 redirect_expiry: Option<Timestamp>,
927 blob_expiry: Option<Timestamp>,
928) -> Option<Timestamp> {
929 match (redirect_expiry, blob_expiry) {
930 (Some(redirect), Some(blob)) => Some(redirect.min(blob)),
931 (Some(expiry), None) | (None, Some(expiry)) => Some(expiry),
932 (None, None) => None,
933 }
934}
935
936fn align_expiry(mut metadata: Metadata, tombstone: &Tombstone) -> Metadata {
941 metadata.time_expires = effective_expiry(tombstone.time_expires, metadata.time_expires);
942 metadata
943}
944
945#[cfg(test)]
946mod tests {
947 use std::num::NonZeroU64;
948 use std::sync::Mutex as StdMutex;
949
950 use objectstore_types::metadata::{ExpirationPolicy, Metadata};
951 use objectstore_types::scope::{Scope, Scopes};
952
953 use super::*;
954 use crate::backend::bigtable::{BigTableBackend, BigTableConfig};
955 use crate::backend::changelog::{InMemoryChangeLog, NoopChangeLog};
956 use crate::backend::gcs::{GcsBackend, GcsConfig};
957 use crate::backend::in_memory::InMemoryBackend;
958 use crate::backend::testing::{Hooks, TestBackend};
959 use crate::change_stream::ChangeStreamFactory;
960 use crate::error::Error;
961 use crate::id::ObjectContext;
962 use crate::stream::{self, ClientStream};
963
964 fn make_context() -> ObjectContext {
965 ObjectContext {
966 usecase: "testing".parse().unwrap(),
967 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
968 }
969 }
970
971 fn make_id(key: &str) -> ObjectId {
972 ObjectId::new(make_context(), key.into())
973 }
974
975 fn make_tiered_storage() -> (
976 TieredStorage,
977 InMemoryBackend,
978 InMemoryBackend,
979 InMemoryChangeLog,
980 ) {
981 let hv = InMemoryBackend::new("in-memory-hv");
982 let lt = InMemoryBackend::new("in-memory-lt");
983 let changelog = InMemoryChangeLog::default();
984 let storage = TieredStorage::new(
985 Box::new(hv.clone()),
986 Box::new(lt.clone()),
987 Box::new(changelog.clone()),
988 );
989 (storage, hv, lt, changelog)
990 }
991
992 async fn resumable_token(
993 storage: &TieredStorage,
994 id: &ObjectId,
995 metadata: &Metadata,
996 length: u64,
997 ) -> Session {
998 let backend_token = storage
999 .create_upload_session(id, metadata, NonZeroU64::new(length).unwrap())
1000 .await
1001 .unwrap()
1002 .unwrap();
1003 Session {
1004 object_id: id.clone(),
1005 upload_length: NonZeroU64::new(length).unwrap(),
1006 backend_token,
1007 }
1008 }
1009
1010 fn upload_revision(session: &Session) -> ObjectId {
1011 TieredResumableToken::decode(&session.backend_token)
1012 .unwrap()
1013 .into_inner_session(session)
1014 .object_id
1015 }
1016
1017 #[tokio::test]
1018 async fn resumable_inmemory() -> anyhow::Result<()> {
1019 let (storage, _, _, _) = make_tiered_storage();
1020 let id = make_id("tiered-resumable-inmemory");
1021 let payload = vec![b'a'; BACKEND_SIZE_THRESHOLD + 1];
1022 let token =
1023 resumable_token(&storage, &id, &Metadata::default(), payload.len() as u64).await;
1024
1025 let revision = upload_revision(&token);
1026 assert!(
1027 storage
1028 .inner
1029 .high_volume
1030 .has_upload_marker(&revision, Timestamp::now())
1031 .await?
1032 );
1033 assert_eq!(
1034 storage.upload_offset(&token).await?,
1035 UploadProgress::Incomplete { offset: 0 }
1036 );
1037 let split = 256 * 1024;
1038 assert_eq!(
1039 storage
1040 .put_chunk(
1041 &token,
1042 0,
1043 split as u64,
1044 stream::single(payload[..split].to_vec())
1045 )
1046 .await?,
1047 UploadProgress::Incomplete {
1048 offset: split as u64
1049 }
1050 );
1051 assert!(
1052 storage
1053 .inner
1054 .high_volume
1055 .has_upload_marker(&revision, Timestamp::now())
1056 .await?
1057 );
1058 assert_eq!(
1059 storage.upload_offset(&token).await?,
1060 UploadProgress::Incomplete {
1061 offset: split as u64
1062 }
1063 );
1064 assert!(storage.get_metadata(&id, Timestamp::now()).await?.is_none());
1065
1066 assert_eq!(
1068 storage
1069 .put_chunk(
1070 &token,
1071 split as u64,
1072 (payload.len() - split) as u64,
1073 stream::single(payload[split..].to_vec())
1074 )
1075 .await?,
1076 UploadProgress::Complete
1077 );
1078 assert!(
1079 !storage
1080 .inner
1081 .high_volume
1082 .has_upload_marker(&revision, Timestamp::now())
1083 .await?
1084 );
1085 assert_eq!(
1086 storage.upload_offset(&token).await.unwrap_err().kind(),
1087 ErrorKind::UploadSessionGone
1088 );
1089 let (_, _, body) = storage
1090 .get_object(&id, Timestamp::now(), None)
1091 .await?
1092 .unwrap();
1093 assert_eq!(stream::read_to_vec(body).await?, payload);
1094 Ok(())
1095 }
1096
1097 #[tokio::test]
1098 async fn resumable_bigtable_and_gcs() -> anyhow::Result<()> {
1099 let streams = ChangeStreamFactory::default();
1100 let lt = GcsBackend::new(
1101 GcsConfig {
1102 endpoint: Some("http://localhost:8087".into()),
1103 bucket: "test-bucket".into(),
1104 tcp_user_timeout: Duration::from_secs(30),
1105 cogs: None,
1106 },
1107 &streams,
1108 )
1109 .await?;
1110 let hv = BigTableBackend::new(
1111 BigTableConfig {
1112 endpoint: Some("localhost:8086".into()),
1113 project_id: "testing".into(),
1114 instance_name: "objectstore".into(),
1115 table_name: "objectstore".into(),
1116 connections: None,
1117 rpc_timeout: Duration::from_secs(2),
1118 cogs: None,
1119 },
1120 &streams,
1121 )
1122 .await?;
1123 let storage = TieredStorage::new(Box::new(hv), Box::new(lt), Box::new(NoopChangeLog));
1124 let id = make_id(&format!("tiered-resumable-{}", uuid::Uuid::now_v7()));
1125 let payload = vec![b'a'; BACKEND_SIZE_THRESHOLD + 1];
1126 let token =
1127 resumable_token(&storage, &id, &Metadata::default(), payload.len() as u64).await;
1128
1129 let error = storage
1130 .put_chunk(&token, 0, 1, stream::single("a"))
1131 .await
1132 .unwrap_err();
1133 assert_eq!(
1134 error.kind(),
1135 ErrorKind::ChunkTooSmall {
1136 chunk_length: 1,
1137 upload_granularity: 256 * 1024,
1138 }
1139 );
1140
1141 let revision = upload_revision(&token);
1142 assert!(
1143 storage
1144 .inner
1145 .high_volume
1146 .has_upload_marker(&revision, Timestamp::now())
1147 .await?
1148 );
1149 assert_eq!(
1150 storage.upload_offset(&token).await?,
1151 UploadProgress::Incomplete { offset: 0 }
1152 );
1153 let split = 256 * 1024;
1154 assert_eq!(
1155 storage
1156 .put_chunk(
1157 &token,
1158 0,
1159 split as u64,
1160 stream::single(payload[..split].to_vec())
1161 )
1162 .await?,
1163 UploadProgress::Incomplete {
1164 offset: split as u64
1165 }
1166 );
1167 assert!(
1168 storage
1169 .inner
1170 .high_volume
1171 .has_upload_marker(&revision, Timestamp::now())
1172 .await?
1173 );
1174 assert_eq!(
1175 storage.upload_offset(&token).await?,
1176 UploadProgress::Incomplete {
1177 offset: split as u64
1178 }
1179 );
1180 assert!(storage.get_metadata(&id, Timestamp::now()).await?.is_none());
1181
1182 assert_eq!(
1184 storage
1185 .put_chunk(
1186 &token,
1187 split as u64,
1188 (payload.len() - split) as u64,
1189 stream::single(payload[split..].to_vec())
1190 )
1191 .await?,
1192 UploadProgress::Complete
1193 );
1194 assert!(
1195 !storage
1196 .inner
1197 .high_volume
1198 .has_upload_marker(&revision, Timestamp::now())
1199 .await?
1200 );
1201 assert_eq!(
1202 storage.upload_offset(&token).await.unwrap_err().kind(),
1203 ErrorKind::UploadSessionGone
1204 );
1205 let (_, _, body) = storage
1206 .get_object(&id, Timestamp::now(), None)
1207 .await?
1208 .unwrap();
1209 assert_eq!(stream::read_to_vec(body).await?, payload);
1210 Ok(())
1211 }
1212
1213 #[tokio::test]
1214 async fn resumable_invalid_chunks() {
1215 let (storage, _, _, _) = make_tiered_storage();
1216 let id = make_id("resumable-invalid");
1217 let length = BACKEND_SIZE_THRESHOLD as u64 + 1;
1218 let token = resumable_token(&storage, &id, &Metadata::default(), length).await;
1219
1220 assert_eq!(
1222 storage
1223 .put_chunk(&token, u64::MAX, 1, stream::single("x"))
1224 .await
1225 .unwrap_err()
1226 .kind(),
1227 ErrorKind::ChunkExceedsUploadLength {
1228 offset: u64::MAX,
1229 content_length: 1,
1230 upload_length: length
1231 }
1232 );
1233 assert_eq!(
1234 storage
1235 .put_chunk(&token, length - 1, 1, stream::single("x"))
1236 .await
1237 .unwrap_err()
1238 .kind(),
1239 ErrorKind::UploadOffsetMismatch { offset: 0 }
1240 );
1241 }
1242
1243 #[tokio::test]
1244 async fn resumable_cancel() {
1245 let (storage, hv, _, _) = make_tiered_storage();
1246 let id = make_id("resumable-invalid");
1247 let token = resumable_token(
1248 &storage,
1249 &id,
1250 &Metadata::default(),
1251 BACKEND_SIZE_THRESHOLD as u64 + 1,
1252 )
1253 .await;
1254
1255 let revision = upload_revision(&token);
1256 assert!(
1257 hv.has_upload_marker(&revision, Timestamp::now())
1258 .await
1259 .unwrap()
1260 );
1261 storage.cancel_upload(&token).await.unwrap();
1262 assert!(
1263 !hv.has_upload_marker(&revision, Timestamp::now())
1264 .await
1265 .unwrap()
1266 );
1267 assert_eq!(
1268 storage.cancel_upload(&token).await.unwrap_err().kind(),
1269 ErrorKind::UploadSessionGone
1270 );
1271 assert_eq!(
1272 storage.upload_offset(&token).await.unwrap_err().kind(),
1273 ErrorKind::UploadSessionGone
1274 );
1275 assert!(!hv.contains(&id));
1276 }
1277
1278 #[derive(Clone, Debug)]
1279 struct ExpiryHook {
1280 label: &'static str,
1281 events: Arc<StdMutex<Vec<&'static str>>>,
1282 reject: bool,
1283 }
1284
1285 type ExpiryTestStorage = (
1286 TieredStorage,
1287 TestBackend<ExpiryHook>,
1288 TestBackend<ExpiryHook>,
1289 Arc<StdMutex<Vec<&'static str>>>,
1290 );
1291
1292 #[async_trait::async_trait]
1293 impl Hooks for ExpiryHook {
1294 async fn set_expiry(
1295 &self,
1296 inner: &InMemoryBackend,
1297 id: &ObjectId,
1298 target: ExpiryUpdate,
1299 access_time: Timestamp,
1300 ) -> Result<SetExpiryResponse> {
1301 self.events.lock().unwrap().push(self.label);
1302 if self.reject {
1303 Ok(SetExpiryResponse::Rejected)
1304 } else {
1305 inner.set_expiry(id, target, access_time).await
1306 }
1307 }
1308
1309 async fn compare_and_update(
1310 &self,
1311 inner: &InMemoryBackend,
1312 id: &ObjectId,
1313 current: Option<&ObjectId>,
1314 update: TieredUpdate,
1315 access_time: Timestamp,
1316 ) -> Result<SetExpiryResponse> {
1317 self.events.lock().unwrap().push(self.label);
1318 if self.reject {
1319 Ok(SetExpiryResponse::Rejected)
1320 } else {
1321 inner
1322 .compare_and_update(id, current, update, access_time)
1323 .await
1324 }
1325 }
1326 }
1327
1328 fn tiered_with_expiry_hooks(hv_reject: bool, lt_reject: bool) -> ExpiryTestStorage {
1329 let events = Arc::new(StdMutex::new(Vec::new()));
1330 let hv = TestBackend::new(ExpiryHook {
1331 label: "hv",
1332 events: Arc::clone(&events),
1333 reject: hv_reject,
1334 });
1335 let lt = TestBackend::new(ExpiryHook {
1336 label: "lt",
1337 events: Arc::clone(&events),
1338 reject: lt_reject,
1339 });
1340 let storage = TieredStorage::new(
1341 Box::new(hv.clone()),
1342 Box::new(lt.clone()),
1343 Box::new(NoopChangeLog),
1344 );
1345 (storage, hv, lt, events)
1346 }
1347
1348 async fn seed_redirect(
1349 hv: &InMemoryBackend,
1350 lt: &InMemoryBackend,
1351 id: &ObjectId,
1352 target: &ObjectId,
1353 expiry: Timestamp,
1354 ) {
1355 let metadata = Metadata {
1356 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_mins(10)),
1357 time_created: expiry.checked_sub(Duration::from_mins(10)),
1358 time_expires: Some(expiry),
1359 ..Default::default()
1360 };
1361 lt.put_object(
1362 target,
1363 &metadata,
1364 stream::single("payload"),
1365 Timestamp::now(),
1366 )
1367 .await
1368 .unwrap();
1369 hv.compare_and_write(
1370 id,
1371 None,
1372 TieredWrite::Tombstone(Tombstone {
1373 target: target.clone(),
1374 time_expires: metadata.time_expires,
1375 }),
1376 Timestamp::now(),
1377 )
1378 .await
1379 .unwrap();
1380 }
1381
1382 #[tokio::test]
1383 async fn set_expiry() {
1384 let (storage, hv, lt, events) = tiered_with_expiry_hooks(false, false);
1385 let id = make_id("tiered-expiry-order");
1386 let target = new_long_term_revision(&id);
1387 let old_expiry = Timestamp::now() + Duration::from_mins(10);
1388 seed_redirect(&hv.inner, <.inner, &id, &target, old_expiry).await;
1389
1390 let created = old_expiry - Duration::from_mins(10);
1391 let requested = created + Duration::from_hours(1);
1392 let blob_expiry = requested + Duration::from_hours(1);
1393 lt.inner
1394 .put_object(
1395 &target,
1396 &Metadata {
1397 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_hours(1)),
1398 time_created: Some(created),
1399 time_expires: Some(blob_expiry),
1400 ..Default::default()
1401 },
1402 stream::single("payload"),
1403 Timestamp::now(),
1404 )
1405 .await
1406 .unwrap();
1407 let update = ExpiryUpdate {
1408 target: ExpiryTarget::FromCreation(Duration::from_hours(1)),
1409 max: Some(Duration::ZERO),
1410 };
1411 assert_eq!(
1412 storage
1413 .set_expiry(&id, update, created)
1414 .await
1415 .unwrap_err()
1416 .kind(),
1417 ErrorKind::InvalidMetadata,
1418 );
1419 assert_eq!(events.lock().unwrap().as_slice(), &["lt"]);
1420 events.lock().unwrap().clear();
1421 assert_eq!(
1422 hv.inner.get(&id).expect_tombstone().time_expires,
1423 Some(old_expiry)
1424 );
1425 assert_eq!(
1426 storage
1427 .set_expiry(
1428 &id,
1429 ExpiryUpdate {
1430 max: Some(Duration::from_hours(1)),
1431 ..update
1432 },
1433 Timestamp::now(),
1434 )
1435 .await
1436 .unwrap(),
1437 SetExpiryResponse::Satisfied(requested)
1438 );
1439 assert_eq!(events.lock().unwrap().as_slice(), &["lt", "hv"]);
1440 assert_eq!(
1441 lt.inner.get(&target).expect_object().0.time_expires,
1442 Some(blob_expiry)
1443 );
1444 assert_eq!(
1445 hv.inner.get(&id).expect_tombstone().time_expires,
1446 Some(requested)
1447 );
1448 }
1449
1450 #[derive(Clone, Debug)]
1451 struct ReplaceInlineOnLookup {
1452 replacement: Metadata,
1453 replaced: Arc<StdMutex<bool>>,
1454 }
1455
1456 #[async_trait::async_trait]
1457 impl Hooks for ReplaceInlineOnLookup {
1458 async fn get_tiered_metadata(
1459 &self,
1460 inner: &InMemoryBackend,
1461 id: &ObjectId,
1462 access_time: Timestamp,
1463 ) -> Result<TieredMetadata> {
1464 let observed = inner.get_tiered_metadata(id, access_time).await?;
1465 let should_replace = {
1466 let mut replaced = self.replaced.lock().unwrap();
1467 !std::mem::replace(&mut *replaced, true)
1468 };
1469 if should_replace {
1470 inner
1471 .put_object(
1472 id,
1473 &self.replacement,
1474 stream::single("replacement"),
1475 access_time,
1476 )
1477 .await?;
1478 }
1479 Ok(observed)
1480 }
1481 }
1482
1483 #[tokio::test]
1484 async fn inline_creation_target_uses_update_snapshot() {
1485 let access_time = Timestamp::from_unix_secs(1_700_000_000).unwrap();
1486 let old_created = access_time - Duration::from_hours(2);
1487 let new_created = access_time - Duration::from_mins(30);
1488 let old_expiry = access_time + Duration::from_mins(10);
1489 let resolved = new_created + Duration::from_hours(2);
1490 let replacement = Metadata {
1491 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1492 time_created: Some(new_created),
1493 time_expires: Some(old_expiry),
1494 ..Default::default()
1495 };
1496 let hv = TestBackend::new(ReplaceInlineOnLookup {
1497 replacement,
1498 replaced: Arc::new(StdMutex::new(false)),
1499 });
1500 let storage = TieredStorage::new(
1501 Box::new(hv.clone()),
1502 Box::new(InMemoryBackend::new("lt")),
1503 Box::new(NoopChangeLog),
1504 );
1505 let id = make_id("inline-update-snapshot");
1506 hv.inner
1507 .put_object(
1508 &id,
1509 &Metadata {
1510 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1511 time_created: Some(old_created),
1512 time_expires: Some(old_expiry),
1513 ..Default::default()
1514 },
1515 stream::single("original"),
1516 access_time,
1517 )
1518 .await
1519 .unwrap();
1520
1521 assert_eq!(
1522 storage
1523 .set_expiry(
1524 &id,
1525 ExpiryTarget::FromCreation(Duration::from_hours(2)).into(),
1526 access_time,
1527 )
1528 .await
1529 .unwrap(),
1530 SetExpiryResponse::Satisfied(resolved)
1531 );
1532 let (metadata, payload) = hv.inner.get(&id).expect_object();
1533 assert_eq!(
1534 metadata.expiration_policy,
1535 ExpirationPolicy::TimeToLive(Duration::from_hours(2))
1536 );
1537 assert_eq!(metadata.time_created, Some(new_created));
1538 assert_eq!(metadata.time_expires, Some(resolved));
1539 assert_eq!(payload, Bytes::from_static(b"replacement"));
1540 }
1541
1542 #[tokio::test]
1543 async fn expiry_hv_conflict() {
1544 let (storage, hv, lt, events) = tiered_with_expiry_hooks(true, false);
1545 let id = make_id("tiered-hv-failure");
1546 let target = new_long_term_revision(&id);
1547 let old_expiry = Timestamp::now() + Duration::from_mins(10);
1548 seed_redirect(&hv.inner, <.inner, &id, &target, old_expiry).await;
1549
1550 let requested = old_expiry + Duration::from_mins(50);
1551 assert_eq!(
1552 storage
1553 .set_expiry(
1554 &id,
1555 ExpiryTarget::FromCreation(Duration::from_hours(1)).into(),
1556 Timestamp::now(),
1557 )
1558 .await
1559 .unwrap(),
1560 SetExpiryResponse::Rejected
1561 );
1562 assert_eq!(events.lock().unwrap().as_slice(), &["lt", "hv"]);
1563 assert_eq!(
1564 hv.inner.get(&id).expect_tombstone().time_expires,
1565 Some(old_expiry)
1566 );
1567 assert_eq!(
1568 lt.inner.get(&target).expect_object().0.time_expires,
1569 Some(requested)
1570 );
1571 assert_eq!(
1572 storage
1573 .get_metadata(&id, Timestamp::now())
1574 .await
1575 .unwrap()
1576 .unwrap()
1577 .time_expires,
1578 Some(old_expiry)
1579 );
1580 assert_eq!(
1581 lt.inner.get(&target).expect_object().0.expiration_policy,
1582 ExpirationPolicy::TimeToLive(Duration::from_hours(1))
1583 );
1584 }
1585
1586 #[tokio::test]
1587 async fn expiry_lt_conflict() {
1588 let (storage, hv, lt, events) = tiered_with_expiry_hooks(false, true);
1589 let id = make_id("tiered-lt-conflict");
1590 let target = new_long_term_revision(&id);
1591 seed_redirect(
1592 &hv.inner,
1593 <.inner,
1594 &id,
1595 &target,
1596 Timestamp::now() + Duration::from_mins(10),
1597 )
1598 .await;
1599
1600 assert_eq!(
1601 storage
1602 .set_expiry(
1603 &id,
1604 ExpiryTarget::At(Timestamp::now() + Duration::from_hours(1)).into(),
1605 Timestamp::now()
1606 )
1607 .await
1608 .unwrap(),
1609 SetExpiryResponse::Rejected
1610 );
1611 assert_eq!(events.lock().unwrap().as_slice(), &["lt"]);
1612 }
1613
1614 #[tokio::test]
1615 async fn expiry_not_found() {
1616 for missing_blob in [false, true] {
1617 let (storage, hv, lt, events) = tiered_with_expiry_hooks(false, false);
1618 let id = make_id("tiered-expiry-missing");
1619 let target = new_long_term_revision(&id);
1620 let access_time = Timestamp::now();
1621 let deadline = access_time + Duration::from_hours(1);
1622 if missing_blob {
1623 seed_redirect(&hv.inner, <.inner, &id, &target, deadline).await;
1624 lt.inner.delete_object(&target, access_time).await.unwrap();
1625 }
1626
1627 assert_eq!(
1628 storage
1629 .set_expiry(&id, ExpiryTarget::At(deadline).into(), access_time)
1630 .await
1631 .unwrap(),
1632 SetExpiryResponse::NotFound
1633 );
1634 let expected: &[&str] = if missing_blob { &["lt"] } else { &[] };
1635 assert_eq!(events.lock().unwrap().as_slice(), expected);
1636 }
1637 }
1638
1639 #[test]
1642 fn revision_id_preserves_context() {
1643 let id = make_id("my-key");
1644 let revised = new_long_term_revision(&id);
1645 assert_eq!(revised.context, id.context);
1646 assert!(
1647 revised.key.starts_with("my-key/"),
1648 "revised key should have /<uuid> suffix, got: {}",
1649 revised.key
1650 );
1651 }
1652
1653 #[test]
1654 fn revision_id_roundtrips_storage_path() {
1655 let id = make_id("original");
1656 let revised = new_long_term_revision(&id);
1657 let path = revised.as_storage_path().to_string();
1658 let parsed = ObjectId::from_storage_path(&path)
1659 .unwrap_or_else(|| panic!("failed to parse '{path}'"));
1660 assert_eq!(parsed, revised);
1661 }
1662
1663 #[test]
1664 fn revision_id_is_unique() {
1665 let id = make_id("base-key");
1666 let a = new_long_term_revision(&id);
1667 let b = new_long_term_revision(&id);
1668 assert_ne!(a.key, b.key, "two calls should produce different keys");
1669 }
1670
1671 #[tokio::test]
1674 async fn get_nonexistent_returns_none() {
1675 let (storage, _hv, _lt, _) = make_tiered_storage();
1676 let id = make_id("does-not-exist");
1677
1678 assert!(
1679 storage
1680 .get_object(&id, Timestamp::now(), None)
1681 .await
1682 .unwrap()
1683 .is_none()
1684 );
1685 assert!(
1686 storage
1687 .get_metadata(&id, Timestamp::now())
1688 .await
1689 .unwrap()
1690 .is_none()
1691 );
1692 }
1693
1694 #[tokio::test]
1695 async fn delete_nonexistent_succeeds() {
1696 let (storage, _hv, _lt, _) = make_tiered_storage();
1697 let id = make_id("does-not-exist");
1698
1699 storage.delete_object(&id, Timestamp::now()).await.unwrap();
1700 }
1701
1702 #[tokio::test]
1705 async fn put_small_object_stores_inline() {
1706 let (storage, hv, lt, _) = make_tiered_storage();
1707 let id = make_id("small");
1708 let payload = b"small payload".to_vec();
1709
1710 storage
1711 .put_object(
1712 &id,
1713 &Metadata::default(),
1714 stream::single(payload.clone()),
1715 Timestamp::now(),
1716 )
1717 .await
1718 .unwrap();
1719
1720 assert!(hv.contains(&id), "expected in high-volume");
1721 assert!(!lt.contains(&id), "leaked to long-term");
1722
1723 let (_, _, s) = storage
1724 .get_object(&id, Timestamp::now(), None)
1725 .await
1726 .unwrap()
1727 .unwrap();
1728 let body = stream::read_to_vec(s).await.unwrap();
1729 assert_eq!(body, payload);
1730
1731 assert!(
1732 storage
1733 .get_metadata(&id, Timestamp::now())
1734 .await
1735 .unwrap()
1736 .is_some(),
1737 "get_metadata should return metadata for inline objects"
1738 );
1739 }
1740
1741 #[tokio::test]
1742 async fn put_large_object_creates_tombstone() {
1743 let (storage, hv, lt, _) = make_tiered_storage();
1744 let id = make_id("large");
1745 let payload = vec![0xCDu8; 2 * 1024 * 1024]; let metadata_in = Metadata {
1747 content_type: "image/png".into(),
1748 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1749 time_expires: Some(Timestamp::now() + Duration::from_hours(1)),
1750 origin: Some("10.0.0.1".into()),
1751 ..Metadata::default()
1752 };
1753
1754 storage
1755 .put_object(
1756 &id,
1757 &metadata_in,
1758 stream::single(payload.clone()),
1759 Timestamp::now(),
1760 )
1761 .await
1762 .unwrap();
1763
1764 let tombstone = hv.get(&id).expect_tombstone();
1766 assert_eq!(tombstone.time_expires, metadata_in.time_expires);
1767 let lt_id = tombstone.target;
1768 assert!(
1769 lt_id.key().starts_with(id.key()),
1770 "tombstone target key should be a revision of the HV key, got: {}",
1771 lt_id.key()
1772 );
1773
1774 let (lt_meta, _) = lt.get(<_id).expect_object();
1776 assert_eq!(lt_meta.content_type, "image/png");
1777 assert_eq!(lt_meta.expiration_policy, metadata_in.expiration_policy);
1778 assert_eq!(lt_meta.time_expires, tombstone.time_expires);
1779
1780 let (_, _, s) = storage
1782 .get_object(&id, Timestamp::now(), None)
1783 .await
1784 .unwrap()
1785 .unwrap();
1786 let body = stream::read_to_vec(s).await.unwrap();
1787 assert_eq!(body, payload);
1788
1789 let metadata = storage
1791 .get_metadata(&id, Timestamp::now())
1792 .await
1793 .unwrap()
1794 .unwrap();
1795 assert_eq!(metadata.content_type, "image/png");
1796 }
1797
1798 #[tokio::test]
1801 async fn reinsert_small_over_large_swaps_to_inline() {
1802 let (storage, hv, lt, _) = make_tiered_storage();
1803 let id = make_id("reinsert-key");
1804
1805 let large_payload = vec![0xABu8; 2 * 1024 * 1024];
1807 storage
1808 .put_object(
1809 &id,
1810 &Metadata::default(),
1811 stream::single(large_payload),
1812 Timestamp::now(),
1813 )
1814 .await
1815 .unwrap();
1816
1817 let lt_id = hv.get(&id).expect_tombstone().target;
1818
1819 let small_payload = vec![0xCDu8; 100]; storage
1823 .put_object(
1824 &id,
1825 &Metadata::default(),
1826 stream::single(small_payload),
1827 Timestamp::now(),
1828 )
1829 .await
1830 .unwrap();
1831
1832 hv.get(&id).expect_object();
1834
1835 storage.join().await;
1837
1838 lt.get(<_id).expect_not_found();
1840 }
1841
1842 #[tokio::test]
1843 async fn overwrite_large_with_large_replaces_revision() {
1844 let (storage, hv, lt, _) = make_tiered_storage();
1845 let id = make_id("overwrite-large");
1846
1847 let payload1 = vec![0xAAu8; 2 * 1024 * 1024];
1848 storage
1849 .put_object(
1850 &id,
1851 &Metadata::default(),
1852 stream::single(payload1),
1853 Timestamp::now(),
1854 )
1855 .await
1856 .unwrap();
1857 let lt_id_1 = hv.get(&id).expect_tombstone().target;
1858
1859 let payload2 = vec![0xBBu8; 2 * 1024 * 1024];
1860 storage
1861 .put_object(
1862 &id,
1863 &Metadata::default(),
1864 stream::single(payload2.clone()),
1865 Timestamp::now(),
1866 )
1867 .await
1868 .unwrap();
1869 let lt_id_2 = hv.get(&id).expect_tombstone().target;
1870
1871 assert_ne!(
1872 lt_id_1, lt_id_2,
1873 "second write should create a new revision"
1874 );
1875
1876 storage.join().await;
1878
1879 lt.get(<_id_1).expect_not_found();
1880 lt.get(<_id_2).expect_object();
1881
1882 let (_, _, s) = storage
1883 .get_object(&id, Timestamp::now(), None)
1884 .await
1885 .unwrap()
1886 .unwrap();
1887 let body = stream::read_to_vec(s).await.unwrap();
1888 assert_eq!(body, payload2);
1889 }
1890
1891 #[tokio::test]
1894 async fn delete_small_object() {
1895 let (storage, hv, _lt, _) = make_tiered_storage();
1896 let id = make_id("delete-small");
1897
1898 storage
1899 .put_object(
1900 &id,
1901 &Metadata::default(),
1902 stream::single("tiny"),
1903 Timestamp::now(),
1904 )
1905 .await
1906 .unwrap();
1907
1908 storage.delete_object(&id, Timestamp::now()).await.unwrap();
1909
1910 hv.get(&id).expect_not_found();
1911 assert!(
1912 storage
1913 .get_object(&id, Timestamp::now(), None)
1914 .await
1915 .unwrap()
1916 .is_none()
1917 );
1918 }
1919
1920 #[tokio::test]
1921 async fn delete_large_object_cleans_up_both_backends() {
1922 let (storage, hv, lt, _) = make_tiered_storage();
1923 let id = make_id("delete-both");
1924 let payload = vec![0u8; 2 * 1024 * 1024]; storage
1927 .put_object(
1928 &id,
1929 &Metadata::default(),
1930 stream::single(payload),
1931 Timestamp::now(),
1932 )
1933 .await
1934 .unwrap();
1935
1936 let lt_id = hv.get(&id).expect_tombstone().target;
1938
1939 storage.delete_object(&id, Timestamp::now()).await.unwrap();
1940
1941 storage.join().await;
1943
1944 assert!(!hv.contains(&id), "tombstone not cleaned up");
1945 assert!(!lt.contains(<_id), "long-term object not cleaned up");
1946 }
1947
1948 #[derive(Debug)]
1949 struct FailDelete;
1950
1951 #[async_trait::async_trait]
1952 impl Hooks for FailDelete {
1953 async fn delete_object(
1954 &self,
1955 _inner: &InMemoryBackend,
1956 _id: &ObjectId,
1957 _access_time: Timestamp,
1958 ) -> Result<DeleteResponse> {
1959 Err(Error::with_source(
1960 ErrorKind::BackendFailure,
1961 std::io::Error::new(
1962 std::io::ErrorKind::ConnectionRefused,
1963 "simulated long-term delete failure",
1964 ),
1965 ))
1966 }
1967 }
1968
1969 #[tokio::test]
1973 async fn delete_succeeds_when_gcs_cleanup_fails() {
1974 let hv = InMemoryBackend::new("hv");
1975 let lt = TestBackend::new(FailDelete);
1976 let log = NoopChangeLog;
1977 let storage = TieredStorage::new(Box::new(hv.clone()), Box::new(lt), Box::new(log));
1978
1979 let id = make_id("fail-delete");
1980 let payload = vec![0xABu8; 2 * 1024 * 1024]; storage
1982 .put_object(
1983 &id,
1984 &Metadata::default(),
1985 stream::single(payload),
1986 Timestamp::now(),
1987 )
1988 .await
1989 .unwrap();
1990
1991 let result = storage.delete_object(&id, Timestamp::now()).await;
1993 assert!(
1994 result.is_ok(),
1995 "delete should succeed despite GCS cleanup failure"
1996 );
1997
1998 hv.get(&id).expect_not_found();
2000
2001 assert!(
2003 storage
2004 .get_object(&id, Timestamp::now(), None)
2005 .await
2006 .unwrap()
2007 .is_none(),
2008 "object should be unreachable after tombstone is deleted"
2009 );
2010 }
2011
2012 #[derive(Debug, Copy, Clone)]
2015 struct CasConflict;
2016
2017 #[async_trait::async_trait]
2018 impl Hooks for CasConflict {
2019 async fn compare_and_write(
2020 &self,
2021 _inner: &InMemoryBackend,
2022 _id: &ObjectId,
2023 _current: Option<&ObjectId>,
2024 _write: TieredWrite,
2025 _access_time: Timestamp,
2026 ) -> Result<bool> {
2027 Ok(false) }
2029 }
2030
2031 #[tokio::test]
2035 async fn put_large_cas_conflict_cleans_up_new_blob() {
2036 let hv = TestBackend::new(CasConflict);
2037 let lt = InMemoryBackend::new("lt");
2038 let log = NoopChangeLog;
2039 let storage = TieredStorage::new(Box::new(hv), Box::new(lt.clone()), Box::new(log));
2040
2041 let id = make_id("cas-conflict-large");
2042 let payload = vec![0xABu8; 2 * 1024 * 1024]; storage
2045 .put_object(
2046 &id,
2047 &Metadata::default(),
2048 stream::single(payload),
2049 Timestamp::now(),
2050 )
2051 .await
2052 .unwrap();
2053
2054 storage.join().await;
2056
2057 assert!(
2058 lt.is_empty(),
2059 "LT blob should be cleaned up after CAS conflict"
2060 );
2061 }
2062
2063 #[tokio::test]
2064 async fn upload_cas_conflict_cleans_up_new_blob() {
2065 let hv = TestBackend::new(CasConflict);
2066 let lt = InMemoryBackend::new("lt");
2067 let storage =
2068 TieredStorage::new(Box::new(hv), Box::new(lt.clone()), Box::new(NoopChangeLog));
2069 let id = make_id("upload-cas-conflict");
2070 let payload = vec![0xAB; BACKEND_SIZE_THRESHOLD + 1];
2071 let session =
2072 resumable_token(&storage, &id, &Metadata::default(), payload.len() as u64).await;
2073
2074 assert_eq!(
2075 storage
2076 .put_chunk(&session, 0, payload.len() as u64, stream::single(payload))
2077 .await
2078 .unwrap(),
2079 UploadProgress::Complete
2080 );
2081 storage.join().await;
2082 assert!(
2083 lt.is_empty(),
2084 "LT blob should be cleaned up after CAS conflict"
2085 );
2086 }
2087
2088 #[tokio::test]
2092 async fn put_small_over_tombstone_cas_conflict_succeeds() {
2093 let inner = InMemoryBackend::new("hv");
2094 let id = make_id("cas-conflict-small");
2095
2096 let tombstone = Tombstone {
2099 target: make_id("lt-object"),
2100 time_expires: None,
2101 };
2102 inner
2103 .compare_and_write(
2104 &id,
2105 None,
2106 TieredWrite::Tombstone(tombstone),
2107 Timestamp::now(),
2108 )
2109 .await
2110 .unwrap();
2111
2112 let lt = InMemoryBackend::new("lt");
2113 let hv = TestBackend::with_inner(inner, CasConflict);
2114 let log = NoopChangeLog;
2115 let storage = TieredStorage::new(Box::new(hv), Box::new(lt), Box::new(log));
2116
2117 storage
2120 .put_object(
2121 &id,
2122 &Metadata::default(),
2123 stream::single("tiny"),
2124 Timestamp::now(),
2125 )
2126 .await
2127 .unwrap();
2128 }
2129
2130 #[derive(Debug)]
2134 struct FailCas(bool);
2135
2136 #[async_trait::async_trait]
2137 impl Hooks for FailCas {
2138 async fn compare_and_write(
2139 &self,
2140 inner: &InMemoryBackend,
2141 id: &ObjectId,
2142 current: Option<&ObjectId>,
2143 write: TieredWrite,
2144 access_time: Timestamp,
2145 ) -> Result<bool> {
2146 if self.0 {
2147 inner
2149 .compare_and_write(id, current, write, access_time)
2150 .await?;
2151 }
2152 Err(Error::with_source(
2153 ErrorKind::BackendFailure,
2154 std::io::Error::new(
2155 std::io::ErrorKind::TimedOut,
2156 "simulated compare_and_write failure",
2157 ),
2158 ))
2159 }
2160 }
2161
2162 #[tokio::test]
2166 async fn no_orphan_when_tombstone_write_fails() {
2167 let lt = InMemoryBackend::new("lt");
2168 let hv = TestBackend::new(FailCas(false));
2169 let log = NoopChangeLog;
2170 let storage = TieredStorage::new(Box::new(hv), Box::new(lt.clone()), Box::new(log));
2171
2172 let id = make_id("orphan-test");
2173 let payload = vec![0xABu8; 2 * 1024 * 1024]; let result = storage
2175 .put_object(
2176 &id,
2177 &Metadata::default(),
2178 stream::single(payload),
2179 Timestamp::now(),
2180 )
2181 .await;
2182
2183 assert!(result.is_err());
2184
2185 storage.join().await;
2187
2188 assert!(lt.is_empty(), "long-term object not cleaned up");
2189 }
2190
2191 #[tokio::test]
2192 async fn upload_cas_failure_cleans_up_new_blob() {
2193 let hv = TestBackend::new(FailCas(false));
2194 let lt = InMemoryBackend::new("lt");
2195 let storage =
2196 TieredStorage::new(Box::new(hv), Box::new(lt.clone()), Box::new(NoopChangeLog));
2197 let id = make_id("upload-cas-failure");
2198 let payload = vec![0xAB; BACKEND_SIZE_THRESHOLD + 1];
2199 let session =
2200 resumable_token(&storage, &id, &Metadata::default(), payload.len() as u64).await;
2201
2202 assert_eq!(
2203 storage
2204 .put_chunk(&session, 0, payload.len() as u64, stream::single(payload))
2205 .await
2206 .unwrap_err()
2207 .kind(),
2208 ErrorKind::BackendFailure
2209 );
2210 storage.join().await;
2211 assert!(
2212 lt.is_empty(),
2213 "LT blob should be cleaned up after CAS failure"
2214 );
2215 }
2216
2217 #[tokio::test]
2221 async fn orphan_tombstone_returns_none() {
2222 let (storage, hv, lt, _) = make_tiered_storage();
2223 let id = make_id("orphan-tombstone");
2224 let payload = vec![0xCDu8; 2 * 1024 * 1024]; storage
2227 .put_object(
2228 &id,
2229 &Metadata::default(),
2230 stream::single(payload),
2231 Timestamp::now(),
2232 )
2233 .await
2234 .unwrap();
2235
2236 let lt_id = hv.get(&id).expect_tombstone().target;
2238
2239 lt.remove(<_id);
2241
2242 assert!(
2243 storage
2244 .get_object(&id, Timestamp::now(), None)
2245 .await
2246 .unwrap()
2247 .is_none(),
2248 "orphan tombstone should resolve to None on get_object"
2249 );
2250 assert!(
2251 storage
2252 .get_metadata(&id, Timestamp::now())
2253 .await
2254 .unwrap()
2255 .is_none(),
2256 "orphan tombstone should resolve to None on get_metadata"
2257 );
2258 }
2259
2260 #[tokio::test]
2265 async fn tombstone_target_is_used_for_reads_and_deletes() {
2266 let hv = InMemoryBackend::new("hv");
2267 let lt = InMemoryBackend::new("lt");
2268 let log = NoopChangeLog;
2269 let storage = TieredStorage::new(Box::new(hv.clone()), Box::new(lt.clone()), Box::new(log));
2270
2271 let hv_id = make_id("hv-key");
2272 let lt_id = make_id("lt-key");
2273 let payload = vec![0xABu8; 100];
2274
2275 lt.put_object(
2277 <_id,
2278 &Metadata::default(),
2279 stream::single(payload.clone()),
2280 Timestamp::now(),
2281 )
2282 .await
2283 .unwrap();
2284 let tombstone = Tombstone {
2285 target: lt_id.clone(),
2286 time_expires: None,
2287 };
2288 hv.compare_and_write(
2289 &hv_id,
2290 None,
2291 TieredWrite::Tombstone(tombstone),
2292 Timestamp::now(),
2293 )
2294 .await
2295 .unwrap();
2296
2297 let (_, _, s) = storage
2299 .get_object(&hv_id, Timestamp::now(), None)
2300 .await
2301 .unwrap()
2302 .unwrap();
2303 let body = stream::read_to_vec(s).await.unwrap();
2304 assert_eq!(body, payload);
2305
2306 storage
2308 .delete_object(&hv_id, Timestamp::now())
2309 .await
2310 .unwrap();
2311 storage.join().await;
2312 assert!(!hv.contains(&hv_id), "tombstone should be removed");
2313 assert!(!lt.contains(<_id), "lt object should be removed");
2314 }
2315
2316 #[tokio::test]
2319 async fn multi_chunk_large_object_chains_buffered_and_remaining() {
2320 let (storage, hv, lt, _) = make_tiered_storage();
2321 let id = make_id("multi-chunk");
2322
2323 let chunk_size = 512 * 1024; let chunk_count = 4; let stream: ClientStream = futures_util::stream::iter(
2328 (0..chunk_count).map(move |i| Ok(Bytes::from(vec![i as u8; chunk_size]))),
2329 )
2330 .boxed();
2331
2332 storage
2333 .put_object(&id, &Metadata::default(), stream, Timestamp::now())
2334 .await
2335 .unwrap();
2336
2337 let lt_id = hv.get(&id).expect_tombstone().target;
2339 let (_, lt_bytes) = lt.get(<_id).expect_object();
2340 assert_eq!(lt_bytes.len(), chunk_size * chunk_count);
2341
2342 for i in 0..chunk_count {
2344 let offset = i * chunk_size;
2345 assert!(
2346 lt_bytes[offset..offset + chunk_size]
2347 .iter()
2348 .all(|&b| b == i as u8),
2349 "data mismatch in chunk {i}"
2350 );
2351 }
2352 }
2353
2354 #[tokio::test]
2360 async fn written_cleanup_after_lost_cas_response() {
2361 let (storage, hv, lt, log) = make_tiered_storage();
2362 let id = make_id("obj");
2363
2364 let payload = vec![0xAAu8; 2 * 1024 * 1024];
2366 storage
2367 .put_object(
2368 &id,
2369 &Metadata::default(),
2370 stream::single(payload.clone()),
2371 Timestamp::now(),
2372 )
2373 .await
2374 .unwrap();
2375 let tombstone1 = hv.get(&id).expect_tombstone().target;
2376
2377 let broken_storage = TieredStorage::new(
2379 Box::new(TestBackend::with_inner(hv.clone(), FailCas(true))),
2380 Box::new(lt.clone()),
2381 Box::new(log.clone()),
2382 );
2383 broken_storage
2384 .put_object(
2385 &id,
2386 &Metadata::default(),
2387 stream::single(payload.clone()),
2388 Timestamp::now(),
2389 )
2390 .await
2391 .unwrap_err(); let tombstone2 = hv.get(&id).expect_tombstone().target;
2393 assert_ne!(tombstone1, tombstone2);
2394
2395 broken_storage.join().await;
2397 lt.get(&tombstone1).expect_not_found();
2398 lt.get(&tombstone2).expect_object();
2399
2400 broken_storage
2402 .delete_object(&id, Timestamp::now())
2403 .await
2404 .unwrap_err();
2405 hv.get(&id).expect_not_found();
2406 broken_storage.join().await;
2407 lt.get(&tombstone2).expect_not_found();
2408
2409 let id = make_id("obj2");
2411 storage
2412 .put_object(
2413 &id,
2414 &Metadata::default(),
2415 stream::single(payload.clone()),
2416 Timestamp::now(),
2417 )
2418 .await
2419 .unwrap();
2420 let tombstone3 = hv.get(&id).expect_tombstone().target;
2421
2422 broken_storage
2424 .put_object(
2425 &id,
2426 &Metadata::default(),
2427 stream::single(&b"small"[..]),
2428 Timestamp::now(),
2429 )
2430 .await
2431 .unwrap_err(); hv.get(&id).expect_object();
2433 broken_storage.join().await;
2434 lt.get(&tombstone3).expect_not_found();
2435 }
2436
2437 #[test]
2441 fn guard_dropped_outside_runtime_does_not_panic() {
2442 let manager = ChangeManager::new(
2443 Box::new(InMemoryBackend::new("hv")),
2444 Box::new(InMemoryBackend::new("lt")),
2445 Box::new(NoopChangeLog),
2446 );
2447
2448 let change = Change {
2449 id: make_id("object-key"),
2450 new: Some(make_id("cleanup-target")),
2451 old: None,
2452 };
2453
2454 let guard = {
2457 let rt = tokio::runtime::Runtime::new().unwrap();
2458 rt.block_on(manager.record(change)).unwrap()
2459 };
2460
2461 drop(guard); }
2463
2464 #[tokio::test(start_paused = true)]
2469 async fn join_waits_for_cleanup_to_complete() {
2470 let (storage, _hv, _lt, _) = make_tiered_storage();
2471 let change = Change {
2472 id: make_id("object-key"),
2473 new: None,
2474 old: None,
2475 };
2476 let mut guard = storage.record_change(change).await.unwrap();
2477
2478 tokio::spawn(async move {
2479 tokio::time::sleep(Duration::from_secs(10)).await;
2480 guard.advance(ChangePhase::Completed);
2481 drop(guard);
2482 });
2483
2484 let join_future = tokio::spawn(async move { storage.join().await });
2485
2486 tokio::time::sleep(Duration::from_secs(9)).await;
2487 assert!(!join_future.is_finished(), "finished before guard dropped");
2488
2489 tokio::time::sleep(Duration::from_secs(2)).await;
2490 assert!(join_future.is_finished(), "finish after guard drops");
2491 }
2492
2493 #[derive(Clone, Debug)]
2500 struct PauseAfterPut {
2501 paused: Arc<tokio::sync::Notify>,
2502 resume: Arc<tokio::sync::Notify>,
2503 }
2504
2505 #[async_trait::async_trait]
2506 impl Hooks for PauseAfterPut {
2507 async fn put_object(
2508 &self,
2509 inner: &InMemoryBackend,
2510 id: &ObjectId,
2511 metadata: &Metadata,
2512 stream: ClientStream,
2513 access_time: Timestamp,
2514 ) -> Result<PutResponse> {
2515 inner.put_object(id, metadata, stream, access_time).await?;
2516 self.paused.notify_one();
2517 self.resume.notified().await;
2518 Ok(())
2519 }
2520 }
2521
2522 #[tokio::test]
2525 async fn dropped_future_triggers_cleanup_and_log_entry_removed() {
2526 let paused = Arc::new(tokio::sync::Notify::new());
2527 let hooks = PauseAfterPut {
2528 paused: Arc::clone(&paused),
2529 resume: Arc::new(tokio::sync::Notify::new()),
2530 };
2531
2532 let lt_inner = InMemoryBackend::new("lt");
2533 let log = InMemoryChangeLog::default();
2534 let storage = TieredStorage::new(
2535 Box::new(InMemoryBackend::new("hv")),
2536 Box::new(TestBackend::with_inner(lt_inner.clone(), hooks)),
2537 Box::new(log.clone()),
2538 );
2539
2540 let id = make_id("drop-test");
2541 let metadata = Metadata::default();
2542 let payload = vec![0xABu8; 2 * 1024 * 1024]; tokio::select! {
2546 result = storage.put_object(&id, &metadata, stream::single(payload), Timestamp::now()) => {
2547 panic!("expected put to pause before completing, got: {result:?}");
2548 }
2549 _ = paused.notified() => {
2550 }
2552 }
2553
2554 storage.join().await;
2556
2557 assert!(lt_inner.is_empty(), "orphaned LT blob was not cleaned up");
2559
2560 let entries = log.scan().await.unwrap();
2562 assert!(
2563 entries.is_empty(),
2564 "changelog entry not removed after cleanup"
2565 );
2566 }
2567}