1use std::fmt;
33use std::future::Future;
34use std::sync::Arc;
35use std::time::Duration;
36
37use bigtable_rs::bigtable::{BigTableConnection, Error as BigTableError, RowCell};
38use bigtable_rs::google::bigtable::v2::{self, mutation};
39use bytes::Bytes;
40use futures_util::TryStreamExt;
41use objectstore_types::metadata::Metadata;
42use objectstore_types::range::{ByteRange, ContentRange};
43use objectstore_types::time::Timestamp;
44use serde::{Deserialize, Serialize};
45use tonic::Code;
46use tracing::Instrument;
47
48use crate::backend::common::{
49 Backend, DeleteResponse, GetResponse, HighVolumeBackend, MetadataResponse, PutResponse,
50 TieredGet, TieredMetadata, TieredUpdate, TieredWrite, Tombstone,
51};
52use crate::change_stream::{
53 ChangeStream, ChangeStreamFactory, CostTrackerStreamConfig, flush_change_stream,
54};
55use crate::error::{Error, ErrorKind, Result, ResultExt as _};
56use crate::gcp_auth::PrefetchingTokenProvider;
57use crate::id::ObjectId;
58use crate::stream::{ChunkedBytes, ClientStream};
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
82pub struct BigTableConfig {
83 pub endpoint: Option<String>,
96
97 pub project_id: String,
105
106 pub instance_name: String,
112
113 pub table_name: String,
121
122 pub connections: Option<usize>,
132
133 #[serde(default = "default_rpc_timeout", with = "humantime_serde")]
144 pub rpc_timeout: Duration,
145
146 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub cogs: Option<CostTrackerStreamConfig>,
158}
159
160fn default_rpc_timeout() -> Duration {
161 Duration::from_secs(2)
162}
163
164const MAX_CHANNEL_AGE: Option<Duration> = Some(Duration::from_mins(50));
172const TOKEN_SCOPES: &[&str] = &["https://www.googleapis.com/auth/bigtable.data"];
174
175const REQUEST_RETRY_COUNT: usize = 2;
177const CAS_RETRY_COUNT: usize = 3;
179
180const COLUMN_PAYLOAD: &[u8] = b"p";
182const COLUMN_METADATA: &[u8] = b"m";
184const COLUMN_REDIRECT: &[u8] = b"r";
186const FILTER_META: &[u8] = b"^[mr]$";
188
189const FAMILY_GC: &str = "fg";
194const FAMILY_MANUAL: &str = "fm";
196
197pub struct BigTableBackend {
199 bigtable: BigTableConnection,
200
201 instance_path: String,
202 table_path: String,
203 table_name: String,
204
205 change_stream: Arc<dyn ChangeStream>,
206}
207
208impl fmt::Debug for BigTableBackend {
209 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 f.debug_struct("BigTableBackend")
211 .field("instance_path", &self.instance_path)
212 .field("table_path", &self.table_path)
213 .field("table_name", &self.table_name)
214 .finish_non_exhaustive()
215 }
216}
217
218fn column_filter(column: &[u8]) -> v2::RowFilter {
220 v2::RowFilter {
221 filter: Some(v2::row_filter::Filter::ColumnQualifierRegexFilter(
222 [b"^", column, b"$"].concat(),
223 )),
224 }
225}
226
227fn legacy_tombstone_filter() -> v2::RowFilter {
232 v2::RowFilter {
233 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
234 filters: vec![
235 column_filter(COLUMN_METADATA),
236 v2::RowFilter {
237 filter: Some(v2::row_filter::Filter::ValueRegexFilter(
238 b"^\\{\"is_redirect_tombstone\":true[,}].*".to_vec(),
239 )),
240 },
241 ],
242 })),
243 }
244}
245
246fn live_row_filter(inner: v2::RowFilter, now: Timestamp) -> v2::RowFilter {
251 v2::RowFilter {
252 filter: Some(v2::row_filter::Filter::Interleave(
253 v2::row_filter::Interleave {
254 filters: vec![
255 v2::RowFilter {
257 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
258 filters: vec![
259 v2::RowFilter {
260 filter: Some(v2::row_filter::Filter::FamilyNameRegexFilter(
261 format!("^{FAMILY_MANUAL}$"),
262 )),
263 },
264 inner.clone(),
265 ],
266 })),
267 },
268 v2::RowFilter {
270 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
271 filters: vec![
272 v2::RowFilter {
273 filter: Some(v2::row_filter::Filter::FamilyNameRegexFilter(
274 format!("^{FAMILY_GC}$"),
275 )),
276 },
277 v2::RowFilter {
278 filter: Some(v2::row_filter::Filter::TimestampRangeFilter(
279 v2::TimestampRange {
280 start_timestamp_micros: now.as_micros() as i64,
281 end_timestamp_micros: 0,
282 },
283 )),
284 },
285 inner,
286 ],
287 })),
288 },
289 ],
290 },
291 )),
292 }
293}
294
295fn tombstone_filter(access_time: Timestamp) -> v2::RowFilter {
303 let filter = v2::RowFilter {
304 filter: Some(v2::row_filter::Filter::Interleave(
305 v2::row_filter::Interleave {
306 filters: vec![column_filter(COLUMN_REDIRECT), legacy_tombstone_filter()],
307 },
308 )),
309 };
310 live_row_filter(filter, access_time)
311}
312
313fn tombstone_predicate(access_time: Timestamp) -> MutatePredicate {
323 MutatePredicate::Exclude(tombstone_filter(access_time))
324}
325
326fn non_tombstone_predicate(access_time: Timestamp) -> MutatePredicate {
339 MutatePredicate::Include(v2::RowFilter {
340 filter: Some(v2::row_filter::Filter::Condition(Box::new(
341 v2::row_filter::Condition {
342 predicate_filter: Some(Box::new(tombstone_filter(access_time))),
343 true_filter: Some(Box::new(v2::RowFilter {
344 filter: Some(v2::row_filter::Filter::BlockAllFilter(true)),
345 })),
346 false_filter: Some(Box::new(v2::RowFilter {
347 filter: Some(v2::row_filter::Filter::PassAllFilter(true)),
348 })),
349 },
350 ))),
351 })
352}
353
354fn exact_value_regex(value: &str) -> Vec<u8> {
359 format!("^{}$", regex::escape(value)).into_bytes()
360}
361
362fn redirect_target_filter(
380 target: &ObjectId,
381 own_id: &ObjectId,
382 access_time: Timestamp,
383) -> v2::RowFilter {
384 let target_path = exact_value_regex(&target.as_storage_path().to_string());
385
386 let exact_match = v2::RowFilter {
387 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
388 filters: vec![
389 column_filter(COLUMN_REDIRECT),
390 v2::RowFilter {
391 filter: Some(v2::row_filter::Filter::ValueRegexFilter(target_path)),
392 },
393 ],
394 })),
395 };
396
397 if target != own_id {
398 return live_row_filter(exact_match, access_time);
399 }
400
401 let empty_redirect_match = v2::RowFilter {
402 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
403 filters: vec![
404 column_filter(COLUMN_REDIRECT),
405 v2::RowFilter {
406 filter: Some(v2::row_filter::Filter::ValueRegexFilter(b"^$".to_vec())),
407 },
408 ],
409 })),
410 };
411
412 let filter = v2::RowFilter {
416 filter: Some(v2::row_filter::Filter::Interleave(
417 v2::row_filter::Interleave {
418 filters: vec![exact_match, empty_redirect_match, legacy_tombstone_filter()],
419 },
420 )),
421 };
422 live_row_filter(filter, access_time)
423}
424
425fn update_predicate(
432 old: &ObjectId,
433 new: &ObjectId,
434 own_id: &ObjectId,
435 access_time: Timestamp,
436) -> MutatePredicate {
437 MutatePredicate::Include(v2::RowFilter {
438 filter: Some(v2::row_filter::Filter::Interleave(
439 v2::row_filter::Interleave {
440 filters: vec![
441 redirect_target_filter(old, own_id, access_time),
442 redirect_target_filter(new, own_id, access_time),
443 ],
444 },
445 )),
446 })
447}
448
449fn optional_target_predicate(
461 target: &ObjectId,
462 own_id: &ObjectId,
463 access_time: Timestamp,
464) -> MutatePredicate {
465 MutatePredicate::Exclude(v2::RowFilter {
466 filter: Some(v2::row_filter::Filter::Condition(Box::new(
467 v2::row_filter::Condition {
468 predicate_filter: Some(Box::new(redirect_target_filter(
469 target,
470 own_id,
471 access_time,
472 ))),
473 true_filter: Some(Box::new(v2::RowFilter {
474 filter: Some(v2::row_filter::Filter::BlockAllFilter(true)),
475 })),
476 false_filter: Some(Box::new(tombstone_filter(access_time))),
477 },
478 ))),
479 })
480}
481
482fn exact_expiry_filter(start: i64) -> Result<v2::RowFilter> {
483 let end = start.checked_add(1).ok_or_else(|| {
484 Error::new(
485 ErrorKind::Internal,
486 "building Bigtable expiration predicate",
487 )
488 })?;
489 Ok(v2::RowFilter {
490 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
491 filters: vec![
492 v2::RowFilter {
493 filter: Some(v2::row_filter::Filter::FamilyNameRegexFilter(format!(
494 "^{FAMILY_GC}$"
495 ))),
496 },
497 v2::RowFilter {
498 filter: Some(v2::row_filter::Filter::TimestampRangeFilter(
499 v2::TimestampRange {
500 start_timestamp_micros: start,
501 end_timestamp_micros: end,
502 },
503 )),
504 },
505 ],
506 })),
507 })
508}
509
510fn inline_expiry_predicate(
512 observed_expiry: i64,
513 access_time: Timestamp,
514) -> Result<MutatePredicate> {
515 let inline_at_expiry = v2::RowFilter {
516 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
517 filters: vec![
518 column_filter(COLUMN_METADATA),
519 exact_expiry_filter(observed_expiry)?,
520 ],
521 })),
522 };
523
524 Ok(MutatePredicate::Include(v2::RowFilter {
525 filter: Some(v2::row_filter::Filter::Condition(Box::new(
526 v2::row_filter::Condition {
527 predicate_filter: Some(Box::new(tombstone_filter(access_time))),
528 true_filter: Some(Box::new(v2::RowFilter {
529 filter: Some(v2::row_filter::Filter::BlockAllFilter(true)),
530 })),
531 false_filter: Some(Box::new(inline_at_expiry)),
532 },
533 ))),
534 }))
535}
536
537fn redirect_expiry_predicate(
538 target: &ObjectId,
539 own_id: &ObjectId,
540 observed_expiry: i64,
541 access_time: Timestamp,
542) -> Result<MutatePredicate> {
543 Ok(MutatePredicate::Include(v2::RowFilter {
544 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
545 filters: vec![
546 redirect_target_filter(target, own_id, access_time),
547 exact_expiry_filter(observed_expiry)?,
548 ],
549 })),
550 }))
551}
552
553#[derive(Clone, Debug)]
558enum MutatePredicate {
559 Include(v2::RowFilter),
563 Exclude(v2::RowFilter),
567}
568
569fn metadata_filter() -> v2::RowFilter {
574 v2::RowFilter {
575 filter: Some(v2::row_filter::Filter::ColumnQualifierRegexFilter(
576 FILTER_META.to_owned(),
577 )),
578 }
579}
580
581fn mutation(mutation: mutation::Mutation) -> v2::Mutation {
582 v2::Mutation {
583 mutation: Some(mutation),
584 }
585}
586
587fn delete_row_mutation() -> v2::Mutation {
589 mutation(mutation::Mutation::DeleteFromRow(
590 mutation::DeleteFromRow {},
591 ))
592}
593
594fn object_mutations(
600 path: &[u8],
601 mut metadata: Metadata,
602 payload: Vec<u8>,
603) -> Result<([v2::Mutation; 3], u64)> {
604 let (family, timestamp_micros) = match metadata.time_expires {
605 None => (FAMILY_MANUAL, -1),
606 Some(deadline) => (FAMILY_GC, deadline.as_micros() as i64),
607 };
608
609 metadata.size = Some(payload.len());
611
612 let metadata_bytes = serde_json::to_vec(&metadata)
613 .context(ErrorKind::Internal, "encoding Bigtable object metadata")?;
614
615 let mutations = [
616 delete_row_mutation(),
618 mutation(mutation::Mutation::SetCell(mutation::SetCell {
619 family_name: family.to_owned(),
620 column_qualifier: COLUMN_PAYLOAD.to_owned(),
621 timestamp_micros,
622 value: payload,
623 })),
624 mutation(mutation::Mutation::SetCell(mutation::SetCell {
625 family_name: family.to_owned(),
626 column_qualifier: COLUMN_METADATA.to_owned(),
627 timestamp_micros,
628 value: metadata_bytes,
629 })),
630 ];
631
632 let size = row_size(path, &mutations);
633 Ok((mutations, size))
634}
635
636fn row_size(path: &[u8], mutations: &[v2::Mutation]) -> u64 {
641 let cells: usize = mutations
642 .iter()
643 .filter_map(|m| match &m.mutation {
644 Some(mutation::Mutation::SetCell(cell)) => Some(cell.value.len()),
645 _ => None,
646 })
647 .sum();
648
649 (path.len() + cells) as u64
650}
651
652fn tombstone_mutations(tombstone: &Tombstone) -> [v2::Mutation; 2] {
657 let (family, timestamp_micros) = match tombstone.time_expires {
658 None => (FAMILY_MANUAL, -1),
659 Some(deadline) => (FAMILY_GC, deadline.as_micros() as i64),
660 };
661
662 [
663 delete_row_mutation(),
664 mutation(mutation::Mutation::SetCell(mutation::SetCell {
665 family_name: family.to_owned(),
666 column_qualifier: COLUMN_REDIRECT.to_owned(),
667 timestamp_micros,
668 value: tombstone.target.as_storage_path().to_string().into_bytes(),
669 })),
670 ]
671}
672
673#[derive(Debug, Deserialize)]
677struct LegacyTombstoneMeta {
678 #[serde(default)]
685 is_redirect_tombstone: bool,
686}
687
688enum RowData {
690 Object {
692 metadata: Metadata,
693 payload: Vec<u8>,
694 expiry_micros: i64,
696 },
697 Tombstone {
699 target: Vec<u8>,
700 time_expires: Option<Timestamp>,
701 expiry_micros: i64,
703 },
704}
705
706impl RowData {
707 fn from_cells(cells: Vec<RowCell>) -> Result<Self> {
714 let mut metadata_opt: Option<Metadata> = None;
715 let mut redirect_detected = false;
716 let mut redirect_target = Vec::new();
717 let mut expire_at = None;
718 let mut expiry_micros = 0;
719 let mut payload = Vec::new();
720
721 for cell in cells {
722 if cell.family_name == FAMILY_GC {
727 expiry_micros = cell.timestamp_micros;
728 expire_at = Some(Timestamp::from_unix_micros(expiry_micros).context(
729 ErrorKind::CorruptData,
730 "decoding Bigtable expiration timestamp",
731 )?);
732 }
733
734 match cell.qualifier.as_slice() {
735 COLUMN_REDIRECT => {
736 redirect_detected = true;
737 redirect_target = cell.value;
738 }
739 COLUMN_PAYLOAD => {
740 payload = cell.value;
741 }
742 COLUMN_METADATA => {
743 if let Ok(legacy_meta) =
744 serde_json::from_slice::<LegacyTombstoneMeta>(&cell.value)
745 && legacy_meta.is_redirect_tombstone
746 {
747 redirect_detected = true;
748 objectstore_metrics::count!("bigtable.legacy_tombstone_read");
749 } else {
750 metadata_opt = Some(serde_json::from_slice(&cell.value).context(
751 ErrorKind::CorruptData,
752 "decoding Bigtable object metadata",
753 )?);
754 }
755 }
756 _ => {}
757 }
758 }
759
760 Ok(if redirect_detected {
761 RowData::Tombstone {
762 target: redirect_target,
763 time_expires: expire_at,
764 expiry_micros,
765 }
766 } else {
767 let mut metadata = metadata_opt.unwrap_or_default();
769 metadata.time_expires = expire_at;
770 RowData::Object {
771 metadata,
772 payload,
773 expiry_micros,
774 }
775 })
776 }
777
778 fn time_expires(&self) -> Option<Timestamp> {
780 match self {
781 RowData::Object { metadata, .. } => metadata.time_expires,
782 RowData::Tombstone { time_expires, .. } => *time_expires,
783 }
784 }
785
786 fn expires_before(&self, time: Timestamp) -> bool {
790 self.time_expires().is_some_and(|ts| ts < time)
791 }
792}
793
794fn parse_redirect_target(redirect_path: &[u8], tombstone_id: &ObjectId) -> Result<ObjectId> {
800 if redirect_path.is_empty() {
801 objectstore_metrics::count!("bigtable.empty_redirect_read");
802 Ok(tombstone_id.clone())
803 } else {
804 let redirect_str = std::str::from_utf8(redirect_path)
805 .context(ErrorKind::CorruptData, "decoding Bigtable redirect target")?;
806 ObjectId::from_storage_path(redirect_str)
807 .ok_or_else(|| Error::new(ErrorKind::CorruptData, "parsing Bigtable redirect target"))
808 }
809}
810
811impl BigTableBackend {
812 pub async fn new(
817 config: BigTableConfig,
818 streams: &ChangeStreamFactory,
819 ) -> anyhow::Result<Self> {
820 let BigTableConfig {
821 endpoint,
822 project_id,
823 instance_name,
824 table_name,
825 connections,
826 rpc_timeout,
827 cogs,
828 } = config;
829 let change_stream = streams.build(cogs.as_ref());
830
831 let bigtable = if let Some(ref endpoint) = endpoint {
832 BigTableConnection::new_with_emulator(
833 endpoint,
834 &project_id,
835 &instance_name,
836 false, Some(rpc_timeout),
838 )?
839 } else {
840 let token_provider = PrefetchingTokenProvider::gcp_auth(TOKEN_SCOPES).await?;
841 BigTableConnection::new_with_managed_transport(
842 &project_id,
843 &instance_name,
844 false, Some(rpc_timeout),
846 Arc::new(token_provider),
847 connections.unwrap_or(1),
848 true, None, MAX_CHANNEL_AGE,
851 )
852 .await?
853 };
854
855 let client = bigtable.client();
856
857 Ok(Self {
858 bigtable,
859 instance_path: format!("projects/{project_id}/instances/{instance_name}"),
860 table_path: client.get_full_table_name(&table_name),
861 table_name,
862 change_stream,
863 })
864 }
865
866 #[tracing::instrument(level = "debug", fields(action), skip_all)]
870 async fn read_row(
871 &self,
872 path: &[u8],
873 action: &'static str,
874 access_time: Timestamp,
875 filter: Option<v2::RowFilter>,
876 ) -> Result<Option<RowData>> {
877 let request = v2::ReadRowsRequest {
878 table_name: self.table_path.clone(),
879 rows: Some(v2::RowSet {
880 row_keys: vec![path.to_owned()],
881 row_ranges: vec![],
882 }),
883 filter,
884 rows_limit: 1,
885 ..Default::default()
886 };
887
888 let response = retry(action, || async {
889 self.bigtable.client().read_rows(request.clone()).await
890 })
891 .await?;
892 debug_assert!(response.len() <= 1, "Expected at most one row");
893
894 let Some((_, cells)) = response.into_iter().next() else {
895 objectstore_log::debug!("Object not found");
896 return Ok(None);
897 };
898
899 let row = RowData::from_cells(cells)?;
900 Ok(if row.expires_before(access_time) {
901 None
902 } else {
903 Some(row)
904 })
905 }
906
907 #[tracing::instrument(level = "debug", fields(action), skip_all)]
908 async fn mutate(
909 &self,
910 path: Vec<u8>,
911 mutations: impl Into<Vec<v2::Mutation>>,
912 action: &'static str,
913 ) -> Result<v2::MutateRowResponse> {
914 let request = v2::MutateRowRequest {
915 table_name: self.table_path.clone(),
916 row_key: path,
917 mutations: mutations.into(),
918 ..Default::default()
919 };
920
921 let response = retry(action, || async {
922 self.bigtable.client().mutate_row(request.clone()).await
923 })
924 .await?;
925
926 Ok(response.into_inner())
927 }
928
929 async fn put_row(
931 &self,
932 path: Vec<u8>,
933 metadata: Metadata,
934 payload: Vec<u8>,
935 action: &'static str,
936 ) -> Result<(v2::MutateRowResponse, u64)> {
937 let (mutations, size) = object_mutations(&path, metadata, payload)?;
938 let response = self.mutate(path, mutations, action).await?;
939 Ok((response, size))
940 }
941
942 #[tracing::instrument(level = "debug", fields(action = context), skip_all)]
944 async fn check_and_mutate(
945 &self,
946 row_key: Vec<u8>,
947 predicate: MutatePredicate,
948 mutations: impl Into<Vec<v2::Mutation>>,
949 context: &'static str,
950 ) -> Result<bool> {
951 let (filter, true_mutations, false_mutations, success_on_match) = match predicate {
952 MutatePredicate::Include(f) => (f, mutations.into(), vec![], true),
953 MutatePredicate::Exclude(f) => (f, vec![], mutations.into(), false),
954 };
955
956 let request = v2::CheckAndMutateRowRequest {
957 table_name: self.table_path.clone(),
958 row_key,
959 predicate_filter: Some(filter),
960 true_mutations,
961 false_mutations,
962 ..Default::default()
963 };
964
965 let future = retry(context, || async {
966 self.bigtable
967 .client()
968 .check_and_mutate_row(request.clone())
969 .await
970 });
971
972 Ok(future.await?.predicate_matched == success_on_match)
973 }
974}
975
976#[async_trait::async_trait]
977impl Backend for BigTableBackend {
978 fn name(&self) -> &'static str {
979 "bigtable"
980 }
981
982 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
983 async fn put_object(
984 &self,
985 id: &ObjectId,
986 metadata: &Metadata,
987 mut stream: ClientStream,
988 _access_time: Timestamp,
989 ) -> Result<PutResponse> {
990 objectstore_log::debug!("Writing to Bigtable backend");
991 let path = id.as_storage_path().to_string().into_bytes();
992
993 let mut payload = ChunkedBytes::new(0);
994 while let Some(chunk) = stream.try_next().await? {
995 payload.push(chunk);
996 }
997
998 let (_, size) = self
999 .put_row(path, metadata.clone(), payload.into_bytes().into(), "put")
1000 .await?;
1001 self.change_stream.write(id, size, metadata.time_expires);
1002
1003 Ok(())
1004 }
1005
1006 #[tracing::instrument(level = "debug", skip(self))]
1007 async fn get_object(
1008 &self,
1009 id: &ObjectId,
1010 access_time: Timestamp,
1011 range: Option<ByteRange>,
1012 ) -> Result<GetResponse> {
1013 match self.get_tiered_object(id, access_time, range).await? {
1014 TieredGet::Object(metadata, content_range, payload) => {
1015 Ok(Some((metadata, content_range, payload)))
1016 }
1017 TieredGet::Tombstone(_) => Err(ErrorKind::UnexpectedTombstone.into()),
1018 TieredGet::NotFound => Ok(None),
1019 }
1020 }
1021
1022 #[tracing::instrument(level = "debug", skip(self))]
1023 async fn get_metadata(
1024 &self,
1025 id: &ObjectId,
1026 access_time: Timestamp,
1027 ) -> Result<MetadataResponse> {
1028 match self.get_tiered_metadata(id, access_time).await? {
1029 TieredMetadata::Object(metadata) => Ok(Some(metadata)),
1030 TieredMetadata::Tombstone(_) => Err(ErrorKind::UnexpectedTombstone.into()),
1031 TieredMetadata::NotFound => Ok(None),
1032 }
1033 }
1034
1035 async fn set_expiry(
1036 &self,
1037 id: &ObjectId,
1038 expire_at: Timestamp,
1039 access_time: Timestamp,
1040 ) -> Result<bool> {
1041 self.compare_and_update(id, None, TieredUpdate::SetExpiry(expire_at), access_time)
1042 .await
1043 }
1044
1045 #[tracing::instrument(level = "debug", skip(self))]
1046 async fn delete_object(
1047 &self,
1048 id: &ObjectId,
1049 _access_time: Timestamp,
1050 ) -> Result<DeleteResponse> {
1051 objectstore_log::debug!("Deleting from Bigtable backend");
1052
1053 let path = id.as_storage_path().to_string().into_bytes();
1054 self.mutate(path, [delete_row_mutation()], "delete").await?;
1055 self.change_stream.delete(id);
1056
1057 Ok(())
1058 }
1059
1060 async fn join(&self) {
1061 flush_change_stream(&self.change_stream).await;
1062 }
1063}
1064
1065#[async_trait::async_trait]
1066impl HighVolumeBackend for BigTableBackend {
1067 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
1068 async fn put_non_tombstone(
1069 &self,
1070 id: &ObjectId,
1071 metadata: &Metadata,
1072 payload: Bytes,
1073 access_time: Timestamp,
1074 ) -> Result<Option<Tombstone>> {
1075 objectstore_log::debug!("Conditional put to Bigtable backend");
1076
1077 let path = id.as_storage_path().to_string().into_bytes();
1078 let (mutations, size) = object_mutations(&path, metadata.clone(), payload.to_vec())?;
1079
1080 for _ in 0..CAS_RETRY_COUNT {
1081 let write_succeeded = self
1082 .check_and_mutate(
1083 path.clone(),
1084 tombstone_predicate(access_time),
1085 mutations.clone(),
1086 "put_non_tombstone",
1087 )
1088 .await?;
1089
1090 if write_succeeded {
1091 self.change_stream.write(id, size, metadata.time_expires);
1092 return Ok(None);
1093 }
1094
1095 let row = self
1097 .read_row(
1098 &path,
1099 "put_non_tombstone",
1100 access_time,
1101 Some(metadata_filter()),
1102 )
1103 .await?;
1104
1105 match row {
1106 Some(RowData::Tombstone {
1107 target,
1108 time_expires,
1109 ..
1110 }) => {
1111 return Ok(Some(Tombstone {
1112 target: parse_redirect_target(&target, id)?,
1113 time_expires,
1114 }));
1115 }
1116 Some(RowData::Object { .. }) => continue,
1118 None => continue,
1120 }
1121 }
1122
1123 Err(Error::new(
1124 ErrorKind::Internal,
1125 "Bigtable put race exhausted",
1126 ))
1127 }
1128
1129 #[tracing::instrument(level = "debug", skip(self))]
1130 async fn get_tiered_object(
1131 &self,
1132 id: &ObjectId,
1133 access_time: Timestamp,
1134 range: Option<ByteRange>,
1135 ) -> Result<TieredGet> {
1136 objectstore_log::debug!("Reading from Bigtable backend");
1137 let path = id.as_storage_path().to_string().into_bytes();
1138
1139 let Some(row) = self
1140 .read_row(&path, "get_tiered_object", access_time, None)
1141 .await?
1142 else {
1143 return Ok(TieredGet::NotFound);
1144 };
1145
1146 Ok(match row {
1147 RowData::Tombstone {
1148 target,
1149 time_expires,
1150 ..
1151 } => TieredGet::Tombstone(Tombstone {
1152 target: parse_redirect_target(&target, id)?,
1153 time_expires,
1154 }),
1155 RowData::Object {
1156 metadata, payload, ..
1157 } => {
1158 let mut metadata = metadata;
1159 let payload = Bytes::from(payload);
1160 if metadata.size.is_none() {
1161 metadata.size = Some(payload.len());
1163 }
1164
1165 let (content_range, payload) = apply_range(payload, range)?;
1166 TieredGet::Object(metadata, content_range, crate::stream::single(payload))
1167 }
1168 })
1169 }
1170
1171 #[tracing::instrument(level = "debug", skip(self))]
1172 async fn get_tiered_metadata(
1173 &self,
1174 id: &ObjectId,
1175 access_time: Timestamp,
1176 ) -> Result<TieredMetadata> {
1177 objectstore_log::debug!("Reading metadata from Bigtable backend");
1178 let path = id.as_storage_path().to_string().into_bytes();
1179
1180 let row_opt = self
1184 .read_row(
1185 &path,
1186 "get_tiered_metadata",
1187 access_time,
1188 Some(metadata_filter()),
1189 )
1190 .await?;
1191 let Some(row) = row_opt else {
1192 return Ok(TieredMetadata::NotFound);
1193 };
1194
1195 Ok(match row {
1196 RowData::Tombstone {
1197 target,
1198 time_expires,
1199 ..
1200 } => TieredMetadata::Tombstone(Tombstone {
1201 target: parse_redirect_target(&target, id)?,
1202 time_expires,
1203 }),
1204 RowData::Object { metadata, .. } => TieredMetadata::Object(metadata),
1205 })
1206 }
1207
1208 #[tracing::instrument(level = "debug", skip(self))]
1209 async fn compare_and_update(
1210 &self,
1211 id: &ObjectId,
1212 current: Option<&ObjectId>,
1213 update: TieredUpdate,
1214 access_time: Timestamp,
1215 ) -> Result<bool> {
1216 let TieredUpdate::SetExpiry(expire_at) = update;
1217 let path = id.as_storage_path().to_string().into_bytes();
1218
1219 let Some(row) = self
1222 .read_row(&path, "set_expiry", access_time, None)
1223 .await?
1224 else {
1225 return Ok(false);
1226 };
1227
1228 let (predicate, mutations): (_, Vec<_>) = match row {
1229 RowData::Object {
1230 metadata,
1231 payload,
1232 expiry_micros,
1233 } => {
1234 if current.is_some() {
1235 return Ok(false); }
1237 let Some(old_expiry) = metadata.time_expires else {
1238 return Ok(false);
1239 };
1240
1241 if old_expiry < access_time {
1242 return Ok(false); } else if old_expiry >= expire_at {
1244 return Ok(true); }
1246
1247 let predicate = inline_expiry_predicate(expiry_micros, access_time)?;
1251 let mut metadata = metadata;
1252 metadata.time_expires = Some(expire_at);
1253 let (mutations, _) = object_mutations(&path, metadata, payload)?;
1254 (predicate, mutations.into())
1255 }
1256 RowData::Tombstone {
1257 target,
1258 time_expires,
1259 expiry_micros,
1260 } => {
1261 let Some(expected) = current else {
1262 return Ok(false); };
1264 let Some(old_expiry) = time_expires else {
1265 return Ok(false);
1266 };
1267
1268 let target = parse_redirect_target(&target, id)?;
1269 if target != *expected || old_expiry < access_time {
1270 return Ok(false); } else if old_expiry >= expire_at {
1272 return Ok(true); }
1274
1275 let predicate =
1276 redirect_expiry_predicate(expected, id, expiry_micros, access_time)?;
1277 let tombstone = Tombstone {
1278 target,
1279 time_expires: Some(expire_at),
1280 };
1281 (predicate, tombstone_mutations(&tombstone).into())
1282 }
1283 };
1284
1285 let applied = self
1286 .check_and_mutate(path, predicate, mutations, "set_expiry")
1287 .await?;
1288
1289 if applied {
1290 self.change_stream.update(id, Some(expire_at));
1291 }
1292
1293 Ok(applied)
1294 }
1295
1296 #[tracing::instrument(level = "debug", skip(self))]
1297 async fn delete_non_tombstone(
1298 &self,
1299 id: &ObjectId,
1300 access_time: Timestamp,
1301 ) -> Result<Option<Tombstone>> {
1302 objectstore_log::debug!("Conditional delete from Bigtable backend");
1303
1304 let path = id.as_storage_path().to_string().into_bytes();
1305
1306 for _ in 0..CAS_RETRY_COUNT {
1307 let deleted = self
1308 .check_and_mutate(
1309 path.clone(),
1310 non_tombstone_predicate(access_time),
1311 [delete_row_mutation()],
1312 "delete_non_tombstone",
1313 )
1314 .await?;
1315
1316 if deleted {
1317 self.change_stream.delete(id);
1318 return Ok(None);
1319 }
1320
1321 let row = self
1324 .read_row(
1325 &path,
1326 "delete_non_tombstone",
1327 access_time,
1328 Some(metadata_filter()),
1329 )
1330 .await?;
1331
1332 match row {
1333 Some(RowData::Tombstone {
1334 target,
1335 time_expires,
1336 ..
1337 }) => {
1338 return Ok(Some(Tombstone {
1339 target: parse_redirect_target(&target, id)?,
1340 time_expires,
1341 }));
1342 }
1343 Some(RowData::Object { .. }) => continue,
1345 None => return Ok(None),
1347 }
1348 }
1349
1350 Err(Error::new(
1351 ErrorKind::Internal,
1352 "Bigtable delete race exhausted",
1353 ))
1354 }
1355
1356 #[tracing::instrument(level = "debug", skip(self, write))]
1357 async fn compare_and_write(
1358 &self,
1359 id: &ObjectId,
1360 current: Option<&ObjectId>,
1361 write: TieredWrite,
1362 access_time: Timestamp,
1363 ) -> Result<bool> {
1364 objectstore_log::debug!("CAS put to Bigtable backend");
1365
1366 let path = id.as_storage_path().to_string().into_bytes();
1367 let predicate = match (current, write.target()) {
1368 (Some(old), Some(new)) => update_predicate(old, new, id, access_time),
1369 (Some(target), None) => optional_target_predicate(target, id, access_time),
1370 (None, Some(target)) => optional_target_predicate(target, id, access_time),
1371 (None, None) => tombstone_predicate(access_time),
1372 };
1373
1374 let (mutations, expires_at): (Vec<v2::Mutation>, Option<Option<Timestamp>>) = match write {
1378 TieredWrite::Tombstone(tombstone) => (
1379 tombstone_mutations(&tombstone).into(),
1380 Some(tombstone.time_expires),
1381 ),
1382 TieredWrite::Object(m, p) => {
1383 let expires_at = m.time_expires;
1384 let (mutations, _) = object_mutations(&path, m, p.to_vec())?;
1385 (mutations.into(), Some(expires_at))
1386 }
1387 TieredWrite::Delete => (vec![delete_row_mutation()], None),
1388 };
1389
1390 let written = self
1391 .check_and_mutate(
1392 path.clone(),
1393 predicate,
1394 mutations.clone(),
1395 "compare_and_write",
1396 )
1397 .await?;
1398
1399 match (written, expires_at) {
1400 (false, _) => {}
1402 (true, Some(expires_at)) => {
1404 self.change_stream
1405 .write(id, row_size(&path, &mutations), expires_at)
1406 }
1407 (true, None) => self.change_stream.delete(id),
1409 }
1410
1411 Ok(written)
1412 }
1413}
1414
1415async fn retry<T, F>(context: &'static str, f: impl Fn() -> F) -> Result<T>
1417where
1418 F: Future<Output = Result<T, BigTableError>> + Send,
1419{
1420 let mut retry_count = 0usize;
1421
1422 loop {
1423 let attempt_span = tracing::debug_span!(
1424 "bigtable.request",
1425 action = context,
1426 grpc.status = tracing::field::Empty,
1427 );
1428 let attempt = async {
1429 let result = f().await;
1430 let span = tracing::Span::current();
1431 match &result {
1432 Ok(_) => span.record("grpc.status", "ok"),
1433 Err(BigTableError::RpcError(status)) => {
1434 span.record("grpc.status", tracing::field::debug(status.code()))
1435 }
1436 Err(_) => &span,
1438 };
1439 result
1440 };
1441
1442 match attempt.instrument(attempt_span).await {
1443 Ok(res) => return Ok(res),
1444 Err(e) if retry_count >= REQUEST_RETRY_COUNT || !is_retryable(&e) => {
1445 objectstore_metrics::count!("bigtable.failures", action = context);
1446 return Err(e).context(
1447 ErrorKind::BackendFailure,
1448 format!("running Bigtable {context}"),
1449 );
1450 }
1451 Err(e) => {
1452 retry_count += 1;
1453 objectstore_metrics::count!("bigtable.retries", action = context);
1454 objectstore_log::warn!(!!&e, retry_count, context, "Retrying request");
1455 }
1456 }
1457 }
1458}
1459
1460fn is_retryable(error: &BigTableError) -> bool {
1461 match error {
1462 BigTableError::GCPAuthError(_) => true,
1464 BigTableError::TransportError(_) => true,
1466 BigTableError::IoError(_) => true,
1468 BigTableError::TimeoutError(_) => true,
1469
1470 BigTableError::RpcError(status) => match status.code() {
1472 Code::Unavailable => true,
1474 Code::Cancelled => true,
1476 Code::DeadlineExceeded => true,
1477 Code::Unauthenticated => true,
1479 Code::Aborted => true,
1481 Code::Internal => true,
1482 Code::FailedPrecondition => true,
1483 Code::Unknown => true,
1484 _ => false,
1485 },
1486 _ => false,
1487 }
1488}
1489
1490fn apply_range(payload: Bytes, range: Option<ByteRange>) -> Result<(Option<ContentRange>, Bytes)> {
1496 let Some(byte_range) = range else {
1497 return Ok((None, payload));
1498 };
1499
1500 let total = payload.len() as u64;
1501 let content_range = byte_range
1502 .resolve(total)
1503 .ok_or(ErrorKind::RangeNotSatisfiable { total })?;
1504
1505 let sliced = payload.slice(content_range.start as usize..content_range.end as usize + 1);
1506 Ok((Some(content_range), sliced))
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511 use std::collections::BTreeMap;
1512
1513 use anyhow::Result;
1514 #[cfg(feature = "storage-cogs")]
1515 use objectstore_inventory_tracker::OpType;
1516 #[cfg(feature = "storage-cogs")]
1517 use objectstore_inventory_tracker::test_utils::DummyProducer;
1518
1519 use objectstore_types::metadata::ExpirationPolicy;
1520 use objectstore_types::scope::{Scope, Scopes};
1521
1522 use super::*;
1523 use crate::id::ObjectContext;
1524 use crate::stream;
1525
1526 fn test_config() -> BigTableConfig {
1532 BigTableConfig {
1533 endpoint: Some("localhost:8086".into()),
1534 project_id: "testing".into(),
1535 instance_name: "objectstore".into(),
1536 table_name: "objectstore".into(),
1537 connections: None,
1538 rpc_timeout: default_rpc_timeout(),
1539 cogs: None,
1540 }
1541 }
1542
1543 async fn create_test_backend() -> Result<BigTableBackend> {
1544 BigTableBackend::new(test_config(), &ChangeStreamFactory::default()).await
1545 }
1546
1547 #[cfg(feature = "storage-cogs")]
1548 async fn create_test_backend_with_change_stream() -> Result<(BigTableBackend, DummyProducer)> {
1549 let (streams, producer) = crate::change_stream::dummy_factory();
1550 let config = BigTableConfig {
1551 cogs: Some(CostTrackerStreamConfig {
1552 shared_resource_id: "bigtable_objectstore".into(),
1553 sample_rate: 1.0,
1554 }),
1555 ..test_config()
1556 };
1557
1558 Ok((BigTableBackend::new(config, &streams).await?, producer))
1559 }
1560
1561 fn make_id() -> ObjectId {
1562 ObjectId::random(ObjectContext {
1563 usecase: "testing".into(),
1564 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1565 })
1566 }
1567
1568 async fn create_object(
1569 backend: &BigTableBackend,
1570 id: &ObjectId,
1571 metadata: &Metadata,
1572 payload: &[u8],
1573 now: Timestamp,
1574 ) -> Result<()> {
1575 let path = id.as_storage_path().to_string().into_bytes();
1576 let mut metadata = metadata.clone();
1579 if metadata.time_expires.is_none() {
1580 metadata.time_expires = metadata.expiration_policy.expires_in().map(|ttl| now + ttl);
1581 }
1582 let (mutations, _) = object_mutations(&path, metadata, payload.to_vec())?;
1583 backend.mutate(path, mutations, "test-setup").await?;
1584 Ok(())
1585 }
1586
1587 async fn create_tombstone(
1588 backend: &BigTableBackend,
1589 id: &ObjectId,
1590 tombstone: &Tombstone,
1591 ) -> Result<()> {
1592 let path = id.as_storage_path().to_string().into_bytes();
1593 let mutations = tombstone_mutations(tombstone);
1594 backend.mutate(path, mutations, "test-setup").await?;
1595 Ok(())
1596 }
1597
1598 async fn write_legacy_tombstone(
1600 backend: &BigTableBackend,
1601 id: &ObjectId,
1602 expiration_policy: ExpirationPolicy,
1603 time_expires: Option<Timestamp>,
1604 ) -> Result<()> {
1605 let meta = if expiration_policy.is_manual() {
1606 r#"{"is_redirect_tombstone":true}"#.to_owned()
1607 } else {
1608 let policy_json = serde_json::to_string(&expiration_policy).unwrap();
1609 format!(r#"{{"is_redirect_tombstone":true,"expiration_policy":{policy_json}}}"#)
1610 };
1611
1612 let (family, timestamp_micros) = if expiration_policy.is_manual() {
1613 (FAMILY_MANUAL, -1)
1614 } else {
1615 let t =
1616 time_expires.unwrap_or(Timestamp::now() + expiration_policy.expires_in().unwrap());
1617 (FAMILY_GC, t.as_micros() as i64)
1618 };
1619
1620 let path = id.as_storage_path().to_string().into_bytes();
1621 let mutations = [mutation(mutation::Mutation::SetCell(mutation::SetCell {
1622 family_name: family.to_owned(),
1623 column_qualifier: COLUMN_METADATA.to_owned(),
1624 timestamp_micros,
1625 value: meta.into_bytes(),
1626 }))];
1627
1628 backend.mutate(path, mutations, "test-setup").await?;
1629
1630 Ok(())
1631 }
1632
1633 async fn write_empty_redirect_tombstone(
1635 backend: &BigTableBackend,
1636 id: &ObjectId,
1637 ) -> Result<()> {
1638 let path = id.as_storage_path().to_string().into_bytes();
1639 let mutations = [
1640 mutation(mutation::Mutation::SetCell(mutation::SetCell {
1641 family_name: FAMILY_MANUAL.to_owned(),
1642 column_qualifier: COLUMN_REDIRECT.to_owned(),
1643 timestamp_micros: -1,
1644 value: b"".to_vec(), })),
1646 mutation(mutation::Mutation::SetCell(mutation::SetCell {
1647 family_name: FAMILY_MANUAL.to_owned(),
1648 column_qualifier: b"t".to_vec(),
1649 timestamp_micros: -1,
1650 value: b"{}".to_vec(),
1651 })),
1652 ];
1653
1654 backend.mutate(path, mutations, "test-setup").await?;
1655
1656 Ok(())
1657 }
1658
1659 #[tokio::test]
1663 async fn test_roundtrip() -> Result<()> {
1664 let backend = create_test_backend().await?;
1665
1666 let id = make_id();
1667 let metadata = Metadata {
1668 content_type: "text/plain".into(),
1669 time_created: Some(Timestamp::now()),
1670 custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1671 ..Default::default()
1672 };
1673
1674 backend
1675 .put_object(
1676 &id,
1677 &metadata,
1678 stream::single("hello, world"),
1679 Timestamp::now(),
1680 )
1681 .await?;
1682
1683 let (obj_meta, _, stream) = backend
1684 .get_object(&id, Timestamp::now(), None)
1685 .await?
1686 .unwrap();
1687 let payload = stream::read_to_vec(stream).await?;
1688 assert_eq!(payload, b"hello, world");
1689 assert_eq!(obj_meta.content_type, metadata.content_type);
1690 assert_eq!(obj_meta.custom, metadata.custom);
1691
1692 let head_meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
1693 assert_eq!(head_meta.content_type, metadata.content_type);
1694 assert_eq!(head_meta.custom, metadata.custom);
1695
1696 Ok(())
1697 }
1698
1699 #[tokio::test]
1701 async fn test_time_expires_roundtrip() -> Result<()> {
1702 let backend = create_test_backend().await?;
1703
1704 let id = make_id();
1705 let ttl = Duration::from_hours(2 * 24);
1706 let expires = Timestamp::now() + ttl;
1707 let metadata = Metadata {
1708 expiration_policy: ExpirationPolicy::TimeToLive(ttl),
1709 time_expires: Some(expires),
1710 ..Default::default()
1711 };
1712 create_object(&backend, &id, &metadata, b"data", Timestamp::now()).await?;
1713
1714 let meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
1715 assert_eq!(meta.time_expires, Some(expires));
1716
1717 Ok(())
1718 }
1719
1720 #[tokio::test]
1722 async fn test_nonexistent() -> Result<()> {
1723 let backend = create_test_backend().await?;
1724
1725 let id = make_id();
1726 assert!(
1727 backend
1728 .get_object(&id, Timestamp::now(), None)
1729 .await?
1730 .is_none()
1731 );
1732 assert!(backend.get_metadata(&id, Timestamp::now()).await?.is_none());
1733 backend.delete_object(&id, Timestamp::now()).await?;
1734
1735 Ok(())
1736 }
1737
1738 #[tokio::test]
1739 async fn test_overwrite() -> Result<()> {
1740 let backend = create_test_backend().await?;
1741
1742 let id = make_id();
1743 let first_metadata = Metadata {
1744 custom: BTreeMap::from_iter([("invalid".into(), "invalid".into())]),
1745 ..Default::default()
1746 };
1747 create_object(&backend, &id, &first_metadata, b"hello", Timestamp::now()).await?;
1748
1749 let second_metadata = Metadata {
1750 custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1751 ..Default::default()
1752 };
1753 backend
1754 .put_object(
1755 &id,
1756 &second_metadata,
1757 stream::single("world"),
1758 Timestamp::now(),
1759 )
1760 .await?;
1761
1762 let (meta, _, stream) = backend
1763 .get_object(&id, Timestamp::now(), None)
1764 .await?
1765 .unwrap();
1766 let payload = stream::read_to_vec(stream).await?;
1767 assert_eq!(payload, b"world");
1768 assert_eq!(meta.custom, second_metadata.custom);
1769
1770 Ok(())
1771 }
1772
1773 #[tokio::test]
1774 async fn test_read_after_delete() -> Result<()> {
1775 let backend = create_test_backend().await?;
1776
1777 let id = make_id();
1778 let metadata = Metadata::default();
1779 create_object(&backend, &id, &metadata, b"hello", Timestamp::now()).await?;
1780 backend.delete_object(&id, Timestamp::now()).await?;
1781
1782 assert!(
1783 backend
1784 .get_object(&id, Timestamp::now(), None)
1785 .await?
1786 .is_none()
1787 );
1788
1789 Ok(())
1790 }
1791
1792 #[tokio::test]
1794 async fn test_set_expiry() -> Result<()> {
1795 let backend = create_test_backend().await?;
1796 let tti = Duration::from_hours(2 * 24);
1797 let mut metadata = Metadata {
1798 expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1799 ..Default::default()
1800 };
1801
1802 let past_now = Timestamp::now() - tti + Duration::from_mins(1);
1804
1805 let id = make_id();
1806 metadata.time_expires = Some(past_now + tti);
1807 let path = id.as_storage_path().to_string().into_bytes();
1808 let (mutations, _) = object_mutations(&path, metadata, b"hello, world".to_vec())?;
1809 let mutations = mutations.map(|mut mutation| {
1811 if let Some(mutation::Mutation::SetCell(cell)) = &mut mutation.mutation {
1812 cell.timestamp_micros -= 500_000;
1813 }
1814 mutation
1815 });
1816 backend.mutate(path, mutations, "test-setup").await?;
1817
1818 let (observed, _, _) = backend
1819 .get_object(&id, Timestamp::now(), None)
1820 .await?
1821 .unwrap();
1822 let observed_expiry = observed.time_expires.unwrap();
1823 assert_eq!(
1824 backend
1825 .get_metadata(&id, Timestamp::now())
1826 .await?
1827 .unwrap()
1828 .time_expires,
1829 Some(observed_expiry),
1830 "backend reads must not renew TTI"
1831 );
1832
1833 let requested = Timestamp::now() + tti;
1834 assert!(backend.set_expiry(&id, requested, Timestamp::now()).await?);
1835 assert_eq!(
1836 backend
1837 .get_metadata(&id, Timestamp::now())
1838 .await?
1839 .unwrap()
1840 .time_expires,
1841 Some(requested)
1842 );
1843 let (_, _, stream) = backend
1844 .get_object(&id, Timestamp::now(), None)
1845 .await?
1846 .unwrap();
1847 let payload = stream::read_to_vec(stream).await?;
1848 assert_eq!(payload, b"hello, world");
1849
1850 Ok(())
1851 }
1852
1853 #[tokio::test]
1854 async fn test_expiry_conflict() -> Result<()> {
1855 let backend = create_test_backend().await?;
1856 let missing = make_id();
1857 assert!(
1858 !backend
1859 .set_expiry(
1860 &missing,
1861 Timestamp::now() + Duration::from_hours(2),
1862 Timestamp::now()
1863 )
1864 .await?
1865 );
1866
1867 let id = make_id();
1868 let observed_expiry = Timestamp::now() + Duration::from_hours(1);
1869 let original = Metadata {
1870 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1871 time_expires: Some(observed_expiry),
1872 ..Default::default()
1873 };
1874 create_object(&backend, &id, &original, b"original", Timestamp::now()).await?;
1875
1876 let path = id.as_storage_path().to_string().into_bytes();
1877 let mut extended = original.clone();
1878 extended.time_expires = Some(observed_expiry + Duration::from_hours(1));
1879 let (extension, _) = object_mutations(&path, extended, b"original".to_vec())?;
1880 let predicate =
1881 inline_expiry_predicate(observed_expiry.as_micros() as i64, Timestamp::now())?;
1882
1883 let mut replacement = original.clone();
1884 replacement.time_expires = Some(observed_expiry + Duration::from_mins(1));
1885 create_object(
1886 &backend,
1887 &id,
1888 &replacement,
1889 b"replacement",
1890 Timestamp::now(),
1891 )
1892 .await?;
1893 assert!(
1894 !backend
1895 .check_and_mutate(path, predicate, extension, "test-expiry-conflict")
1896 .await?
1897 );
1898 let (_, _, payload) = backend
1899 .get_object(&id, Timestamp::now(), None)
1900 .await?
1901 .unwrap();
1902 assert_eq!(stream::read_to_vec(payload).await?, b"replacement");
1903 Ok(())
1904 }
1905
1906 #[tokio::test]
1907 async fn test_redirect_expiry() -> Result<()> {
1908 let backend = create_test_backend().await?;
1909 let id = make_id();
1910 let target = ObjectId::random(id.context().clone());
1911 let wrong_target = ObjectId::random(id.context().clone());
1912 let old_expiry = Timestamp::now() + Duration::from_hours(1);
1913 let path = id.as_storage_path().to_string().into_bytes();
1914 let mutations = tombstone_mutations(&Tombstone {
1915 target: target.clone(),
1916 time_expires: Some(old_expiry),
1917 })
1918 .map(|mut mutation| {
1919 if let Some(mutation::Mutation::SetCell(cell)) = &mut mutation.mutation {
1920 cell.timestamp_micros -= 500_000;
1921 }
1922 mutation
1923 });
1924 backend.mutate(path, mutations, "test-setup").await?;
1925
1926 let later = old_expiry + Duration::from_hours(2);
1927 assert!(
1928 !backend
1929 .compare_and_update(
1930 &id,
1931 Some(&wrong_target),
1932 TieredUpdate::SetExpiry(later),
1933 Timestamp::now(),
1934 )
1935 .await?
1936 );
1937 assert!(
1938 backend
1939 .compare_and_update(
1940 &id,
1941 Some(&target),
1942 TieredUpdate::SetExpiry(later),
1943 Timestamp::now()
1944 )
1945 .await?
1946 );
1947 assert!(
1948 backend
1949 .compare_and_update(
1950 &id,
1951 Some(&target),
1952 TieredUpdate::SetExpiry(old_expiry + Duration::from_mins(30)),
1953 Timestamp::now(),
1954 )
1955 .await?
1956 );
1957 let TieredMetadata::Tombstone(tombstone) =
1958 backend.get_tiered_metadata(&id, Timestamp::now()).await?
1959 else {
1960 panic!("expected tombstone");
1961 };
1962 assert_eq!(tombstone.time_expires, Some(later));
1963 Ok(())
1964 }
1965
1966 #[tokio::test]
1969 async fn test_ttl_immediate() -> Result<()> {
1970 let backend = create_test_backend().await?;
1974
1975 let id = make_id();
1976 let metadata = Metadata {
1977 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
1978 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
1979 ..Default::default()
1980 };
1981 create_object(&backend, &id, &metadata, b"hello, world", Timestamp::now()).await?;
1982
1983 assert!(
1984 backend
1985 .get_object(&id, Timestamp::now(), None)
1986 .await?
1987 .is_none()
1988 );
1989
1990 Ok(())
1991 }
1992
1993 #[tokio::test]
1994 async fn test_tti_immediate() -> Result<()> {
1995 let backend = create_test_backend().await?;
1999
2000 let id = make_id();
2001 let metadata = Metadata {
2002 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
2003 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2004 ..Default::default()
2005 };
2006 create_object(&backend, &id, &metadata, b"hello, world", Timestamp::now()).await?;
2007
2008 assert!(
2009 backend
2010 .get_object(&id, Timestamp::now(), None)
2011 .await?
2012 .is_none()
2013 );
2014
2015 Ok(())
2016 }
2017
2018 #[tokio::test]
2027 async fn test_tiered_get() -> Result<()> {
2028 let backend = create_test_backend().await?;
2029
2030 let id = make_id();
2032 assert!(matches!(
2033 backend
2034 .get_tiered_object(&id, Timestamp::now(), None)
2035 .await?,
2036 TieredGet::NotFound
2037 ));
2038 assert!(matches!(
2039 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2040 TieredMetadata::NotFound
2041 ));
2042
2043 let id = make_id();
2045 let put_meta = Metadata {
2046 content_type: "text/plain".into(),
2047 custom: BTreeMap::from_iter([("k".into(), "v".into())]),
2048 ..Default::default()
2049 };
2050 create_object(&backend, &id, &put_meta, b"payload", Timestamp::now()).await?;
2051
2052 let TieredGet::Object(obj_meta, _, obj_stream) = backend
2053 .get_tiered_object(&id, Timestamp::now(), None)
2054 .await?
2055 else {
2056 panic!("expected TieredGet::Object");
2057 };
2058 let obj_payload = stream::read_to_vec(obj_stream).await?;
2059 assert_eq!(obj_payload, b"payload");
2060 assert_eq!(obj_meta.content_type, put_meta.content_type);
2061 assert_eq!(obj_meta.custom, put_meta.custom);
2062
2063 let TieredMetadata::Object(head_meta) =
2064 backend.get_tiered_metadata(&id, Timestamp::now()).await?
2065 else {
2066 panic!("expected TieredMetadata::Object");
2067 };
2068 assert_eq!(head_meta.content_type, put_meta.content_type);
2069 assert_eq!(head_meta.custom, put_meta.custom);
2070
2071 let hv_id = make_id();
2073 let lt_id = ObjectId::random(hv_id.context().clone());
2074 let tombstone = Tombstone {
2075 target: lt_id.clone(),
2076 time_expires: None,
2077 };
2078 create_tombstone(&backend, &hv_id, &tombstone).await?;
2079
2080 match backend
2081 .get_tiered_object(&hv_id, Timestamp::now(), None)
2082 .await?
2083 {
2084 TieredGet::Tombstone(get_t) => assert_eq!(get_t.target, lt_id),
2085 other => panic!("expected TieredGet::Tombstone, got {other:?}"),
2086 }
2087 match backend
2088 .get_tiered_metadata(&hv_id, Timestamp::now())
2089 .await?
2090 {
2091 TieredMetadata::Tombstone(meta_t) => assert_eq!(meta_t.target, lt_id,),
2092 other => panic!("expected TieredMetadata::Tombstone, got {other:?}"),
2093 }
2094
2095 Ok(())
2096 }
2097
2098 #[tokio::test]
2104 async fn test_put_non_tombstone() -> Result<()> {
2105 let backend = create_test_backend().await?;
2106
2107 let id = make_id();
2109 let metadata = Metadata::default();
2110 let result = backend
2111 .put_non_tombstone(
2112 &id,
2113 &metadata,
2114 Bytes::from_static(b"first"),
2115 Timestamp::now(),
2116 )
2117 .await?;
2118 assert_eq!(result, None, "expected None on empty row");
2119 let (_, _, stream) = backend
2120 .get_object(&id, Timestamp::now(), None)
2121 .await?
2122 .unwrap();
2123 assert_eq!(&stream::read_to_vec(stream).await?, b"first");
2124
2125 let id = make_id();
2127 create_object(&backend, &id, &metadata, b"old", Timestamp::now()).await?;
2128 let result = backend
2129 .put_non_tombstone(&id, &metadata, Bytes::from_static(b"new"), Timestamp::now())
2130 .await?;
2131 assert_eq!(result, None, "expected None when overwriting object");
2132 let (_, _, stream) = backend
2133 .get_object(&id, Timestamp::now(), None)
2134 .await?
2135 .unwrap();
2136 assert_eq!(&stream::read_to_vec(stream).await?, b"new");
2137
2138 let hv_id = make_id();
2140 let lt_id = ObjectId::random(hv_id.context().clone());
2141 let tombstone = Tombstone {
2142 target: lt_id.clone(),
2143 time_expires: None,
2144 };
2145 create_tombstone(&backend, &hv_id, &tombstone).await?;
2146 let result = backend
2147 .put_non_tombstone(&hv_id, &metadata, Bytes::new(), Timestamp::now())
2148 .await?;
2149 let returned = result.expect("expected Some(Tombstone) when row is a tombstone");
2150 assert_eq!(returned.target, lt_id);
2151 assert!(
2152 matches!(
2153 backend
2154 .get_tiered_metadata(&hv_id, Timestamp::now())
2155 .await?,
2156 TieredMetadata::Tombstone(_)
2157 ),
2158 "tombstone must still exist after put_non_tombstone"
2159 );
2160
2161 Ok(())
2162 }
2163
2164 #[tokio::test]
2173 async fn test_delete_non_tombstone() -> Result<()> {
2174 let backend = create_test_backend().await?;
2175
2176 let id = make_id();
2178 assert_eq!(
2179 backend.delete_non_tombstone(&id, Timestamp::now()).await?,
2180 None
2181 );
2182
2183 let id = make_id();
2185 let metadata = Metadata::default();
2186 create_object(&backend, &id, &metadata, b"hello, world", Timestamp::now()).await?;
2187 assert_eq!(
2188 backend.delete_non_tombstone(&id, Timestamp::now()).await?,
2189 None
2190 );
2191 assert!(
2192 backend
2193 .get_object(&id, Timestamp::now(), None)
2194 .await?
2195 .is_none()
2196 );
2197
2198 let id = make_id();
2200 let tombstone = Tombstone {
2201 target: id.clone(),
2202 time_expires: None,
2203 };
2204 create_tombstone(&backend, &id, &tombstone).await?;
2205 let tombstone = backend
2206 .delete_non_tombstone(&id, Timestamp::now())
2207 .await?
2208 .expect("expected Some(tombstone)");
2209 assert_eq!(tombstone.target, id, "tombstone target must be returned");
2210 assert!(
2211 matches!(
2212 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2213 TieredMetadata::Tombstone(_)
2214 ),
2215 "tombstone must still exist after delete_non_tombstone"
2216 );
2217
2218 Ok(())
2219 }
2220
2221 #[tokio::test]
2227 async fn test_cas_create_tombstone() -> Result<()> {
2228 let backend = create_test_backend().await?;
2229
2230 let hv_id = make_id();
2231 let lt_id = ObjectId::random(hv_id.context().clone());
2232 let time_expires = Some(Timestamp::now() + Duration::from_hours(1));
2233 let tombstone = Tombstone {
2234 target: lt_id.clone(),
2235 time_expires,
2236 };
2237
2238 let committed = backend
2240 .compare_and_write(
2241 &hv_id,
2242 None,
2243 TieredWrite::Tombstone(tombstone.clone()),
2244 Timestamp::now(),
2245 )
2246 .await?;
2247 assert!(committed, "expected CAS success on empty row");
2248
2249 let TieredMetadata::Tombstone(t) = backend
2251 .get_tiered_metadata(&hv_id, Timestamp::now())
2252 .await?
2253 else {
2254 panic!("expected TieredMetadata::Tombstone");
2255 };
2256 assert_eq!(t.target, lt_id, "target must round-trip via r column");
2257 assert_eq!(t.time_expires, time_expires);
2258 match backend
2259 .get_tiered_object(&hv_id, Timestamp::now(), None)
2260 .await?
2261 {
2262 TieredGet::Tombstone(t) => assert_eq!(t.target, lt_id, "round-trip via r column"),
2263 other => panic!("expected TieredGet::Tombstone, got {other:?}"),
2264 }
2265
2266 assert!(
2268 backend
2269 .get_object(&hv_id, Timestamp::now(), None)
2270 .await
2271 .is_err_and(|error| error.kind() == ErrorKind::UnexpectedTombstone)
2272 );
2273 assert!(
2274 backend
2275 .get_metadata(&hv_id, Timestamp::now())
2276 .await
2277 .is_err_and(|error| error.kind() == ErrorKind::UnexpectedTombstone)
2278 );
2279
2280 let second = backend
2282 .compare_and_write(
2283 &hv_id,
2284 None,
2285 TieredWrite::Tombstone(tombstone),
2286 Timestamp::now(),
2287 )
2288 .await?;
2289 assert!(second, "idempotent retry");
2290
2291 Ok(())
2292 }
2293
2294 #[tokio::test]
2296 async fn test_cas_swap_tombstone() -> Result<()> {
2297 let backend = create_test_backend().await?;
2298
2299 let hv_id = make_id();
2300 let old_lt_id = ObjectId::random(hv_id.context().clone());
2301 let wrong_lt_id = ObjectId::random(hv_id.context().clone());
2302 let new_lt_id = ObjectId::random(hv_id.context().clone());
2303
2304 let tombstone = Tombstone {
2305 target: old_lt_id.clone(),
2306 time_expires: None,
2307 };
2308 create_tombstone(&backend, &hv_id, &tombstone).await?;
2309
2310 let write = TieredWrite::Tombstone(Tombstone {
2312 target: new_lt_id.clone(),
2313 time_expires: None,
2314 });
2315 let swapped = backend
2316 .compare_and_write(&hv_id, Some(&wrong_lt_id), write.clone(), Timestamp::now())
2317 .await?;
2318 assert!(!swapped, "expected CAS failure due to wrong target");
2319 match backend
2320 .get_tiered_metadata(&hv_id, Timestamp::now())
2321 .await?
2322 {
2323 TieredMetadata::Tombstone(t) => assert_eq!(t.target, old_lt_id),
2324 other => panic!("expected tombstone, got {other:?}"),
2325 }
2326
2327 let swapped = backend
2329 .compare_and_write(&hv_id, Some(&old_lt_id), write.clone(), Timestamp::now())
2330 .await?;
2331 assert!(swapped, "expected CAS success with correct target");
2332 match backend
2333 .get_tiered_metadata(&hv_id, Timestamp::now())
2334 .await?
2335 {
2336 TieredMetadata::Tombstone(t) => assert_eq!(t.target, new_lt_id),
2337 other => panic!("expected tombstone, got {other:?}"),
2338 }
2339
2340 let retry = backend
2342 .compare_and_write(&hv_id, Some(&old_lt_id), write, Timestamp::now())
2343 .await?;
2344 assert!(retry, "idempotent retry");
2345
2346 Ok(())
2347 }
2348
2349 #[tokio::test]
2351 async fn test_cas_swap_inline() -> Result<()> {
2352 let backend = create_test_backend().await?;
2353
2354 let id = make_id();
2355 let lt_id = ObjectId::random(id.context().clone());
2356 let wrong_id = ObjectId::random(id.context().clone());
2357
2358 let tombstone = Tombstone {
2359 target: lt_id.clone(),
2360 time_expires: None,
2361 };
2362 create_tombstone(&backend, &id, &tombstone).await?;
2363
2364 let write = TieredWrite::Object(Metadata::default(), Bytes::new());
2366 let swapped = backend
2367 .compare_and_write(&id, Some(&wrong_id), write, Timestamp::now())
2368 .await?;
2369 assert!(!swapped, "expected CAS failure with wrong target");
2370 assert!(matches!(
2371 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2372 TieredMetadata::Tombstone(_)
2373 ));
2374
2375 let payload = Bytes::from_static(b"hello inline");
2377 let write = TieredWrite::Object(Metadata::default(), payload.clone());
2378 let swapped = backend
2379 .compare_and_write(&id, Some(<_id), write.clone(), Timestamp::now())
2380 .await?;
2381 assert!(swapped, "expected CAS success with correct target");
2382 let TieredGet::Object(_, _, stream) = backend
2383 .get_tiered_object(&id, Timestamp::now(), None)
2384 .await?
2385 else {
2386 panic!("expected inline object after swap");
2387 };
2388 assert_eq!(&stream::read_to_vec(stream).await?, payload.as_ref());
2389
2390 let retry = backend
2392 .compare_and_write(&id, Some(<_id), write, Timestamp::now())
2393 .await?;
2394 assert!(retry, "idempotent retry");
2395
2396 Ok(())
2397 }
2398
2399 #[tokio::test]
2401 async fn test_cas_create_object_on_empty_row() -> Result<()> {
2402 let backend = create_test_backend().await?;
2403
2404 let id = make_id();
2405 let payload = Bytes::from_static(b"cas object");
2406 let write = TieredWrite::Object(Metadata::default(), payload.clone());
2407 let committed = backend
2408 .compare_and_write(&id, None, write, Timestamp::now())
2409 .await?;
2410 assert!(committed, "expected CAS success on empty row");
2411
2412 let TieredGet::Object(_, _, stream) = backend
2413 .get_tiered_object(&id, Timestamp::now(), None)
2414 .await?
2415 else {
2416 panic!("expected Object after CAS-create");
2417 };
2418 assert_eq!(&stream::read_to_vec(stream).await?, payload.as_ref());
2419
2420 Ok(())
2421 }
2422
2423 #[tokio::test]
2425 async fn test_cas_delete() -> Result<()> {
2426 let backend = create_test_backend().await?;
2427
2428 let id = make_id();
2429 let lt_id = ObjectId::random(id.context().clone());
2430 let wrong_id = ObjectId::random(id.context().clone());
2431
2432 let tombstone = Tombstone {
2433 target: lt_id.clone(),
2434 time_expires: None,
2435 };
2436 create_tombstone(&backend, &id, &tombstone).await?;
2437
2438 let deleted = backend
2440 .compare_and_write(&id, Some(&wrong_id), TieredWrite::Delete, Timestamp::now())
2441 .await?;
2442 assert!(!deleted, "expected CAS failure with wrong target");
2443 assert!(matches!(
2444 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2445 TieredMetadata::Tombstone(_)
2446 ));
2447
2448 let deleted = backend
2450 .compare_and_write(&id, Some(<_id), TieredWrite::Delete, Timestamp::now())
2451 .await?;
2452 assert!(deleted, "expected CAS delete success");
2453 assert!(matches!(
2454 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2455 TieredMetadata::NotFound
2456 ));
2457
2458 let retry = backend
2460 .compare_and_write(&id, Some(<_id), TieredWrite::Delete, Timestamp::now())
2461 .await?;
2462 assert!(retry, "idempotent retry");
2463
2464 let id2 = make_id();
2466 let fake_lt_id = ObjectId::random(id2.context().clone());
2467 let metadata = Metadata::default();
2468 create_object(&backend, &id2, &metadata, b"data", Timestamp::now()).await?;
2469 let deleted = backend
2470 .compare_and_write(
2471 &id2,
2472 Some(&fake_lt_id),
2473 TieredWrite::Delete,
2474 Timestamp::now(),
2475 )
2476 .await?;
2477 assert!(deleted, "expected idempotent deletion");
2478
2479 Ok(())
2480 }
2481
2482 #[tokio::test]
2489 async fn test_legacy_tombstone_reads() -> Result<()> {
2490 let backend = create_test_backend().await?;
2491
2492 let id = make_id();
2494 write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2495
2496 let TieredMetadata::Tombstone(t) =
2497 backend.get_tiered_metadata(&id, Timestamp::now()).await?
2498 else {
2499 panic!("expected tombstone");
2500 };
2501 assert_eq!(t.time_expires, None);
2502 assert!(matches!(
2503 backend
2504 .get_tiered_object(&id, Timestamp::now(), None)
2505 .await?,
2506 TieredGet::Tombstone(_)
2507 ));
2508
2509 let id = make_id();
2514 let ttl = Duration::from_hours(2 * 24);
2515 write_legacy_tombstone(&backend, &id, ExpirationPolicy::TimeToLive(ttl), None).await?;
2516
2517 let TieredMetadata::Tombstone(t) =
2518 backend.get_tiered_metadata(&id, Timestamp::now()).await?
2519 else {
2520 panic!("expected TieredMetadata::Tombstone");
2521 };
2522 assert!(t.time_expires.is_some());
2523
2524 Ok(())
2525 }
2526
2527 #[tokio::test]
2529 async fn test_legacy_tombstone_tti_upgrade() -> Result<()> {
2530 let backend = create_test_backend().await?;
2531 let id = make_id();
2532 let path = id.as_storage_path().to_string().into_bytes();
2533
2534 let tti = Duration::from_hours(2 * 24);
2535
2536 let old_deadline = Timestamp::now() + Duration::from_mins(1);
2538 write_legacy_tombstone(
2539 &backend,
2540 &id,
2541 ExpirationPolicy::TimeToIdle(tti),
2542 Some(old_deadline),
2543 )
2544 .await?;
2545
2546 let TieredMetadata::Tombstone(_) =
2548 backend.get_tiered_metadata(&id, Timestamp::now()).await?
2549 else {
2550 panic!("expected tombstone");
2551 };
2552 assert_eq!(
2553 backend
2554 .read_row(&path, "test-verify", Timestamp::now(), None)
2555 .await?
2556 .and_then(|row| row.time_expires()),
2557 Some(old_deadline)
2558 );
2559
2560 let requested = Timestamp::now() + tti;
2561 assert!(
2562 backend
2563 .compare_and_update(
2564 &id,
2565 Some(&id),
2566 TieredUpdate::SetExpiry(requested),
2567 Timestamp::now()
2568 )
2569 .await?
2570 );
2571
2572 let new_deadline = match backend
2574 .read_row(&path, "test-verify", Timestamp::now(), None)
2575 .await?
2576 {
2577 Some(RowData::Tombstone { time_expires, .. }) => time_expires.unwrap(),
2578 _ => panic!("expected tombstone row after extension"),
2579 };
2580
2581 assert!(
2582 new_deadline > old_deadline,
2583 "explicit extension should extend tombstone expiry: {old_deadline:?} -> {new_deadline:?}"
2584 );
2585
2586 Ok(())
2587 }
2588
2589 #[tokio::test]
2594 async fn test_legacy_tombstone_conditional_ops() -> Result<()> {
2595 let backend = create_test_backend().await?;
2596
2597 let id = make_id();
2599 write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2600 let t_opt = backend
2601 .put_non_tombstone(&id, &Metadata::default(), Bytes::new(), Timestamp::now())
2602 .await?;
2603 assert_eq!(t_opt.map(|t| t.target).as_ref(), Some(&id));
2604
2605 let id = make_id();
2607 write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2608 let t_opt = backend.delete_non_tombstone(&id, Timestamp::now()).await?;
2609 assert_eq!(t_opt.map(|t| t.target).as_ref(), Some(&id));
2610
2611 let id = make_id();
2613 write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2614 let deleted = backend
2615 .compare_and_write(&id, Some(&id), TieredWrite::Delete, Timestamp::now())
2616 .await?;
2617 assert!(
2618 deleted,
2619 "CAS-delete must succeed on legacy-metadata tombstone"
2620 );
2621 assert!(matches!(
2622 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2623 TieredMetadata::NotFound
2624 ));
2625
2626 let id = make_id();
2628 write_empty_redirect_tombstone(&backend, &id).await?;
2629 let deleted = backend
2630 .compare_and_write(&id, Some(&id), TieredWrite::Delete, Timestamp::now())
2631 .await?;
2632 assert!(
2633 deleted,
2634 "CAS-delete must succeed on empty-redirect tombstone"
2635 );
2636 assert!(matches!(
2637 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2638 TieredMetadata::NotFound
2639 ));
2640
2641 Ok(())
2642 }
2643
2644 #[tokio::test]
2646 async fn test_empty_redirect_falls_back_to_hv_id() -> Result<()> {
2647 let backend = create_test_backend().await?;
2648 let id = make_id();
2649
2650 write_empty_redirect_tombstone(&backend, &id).await?;
2651 match backend.get_tiered_metadata(&id, Timestamp::now()).await? {
2652 TieredMetadata::Tombstone(t) => assert_eq!(t.target, id, "must fall back to hv_id"),
2653 other => panic!("expected tombstone, got {other:?}"),
2654 }
2655
2656 Ok(())
2657 }
2658
2659 #[tokio::test]
2664 async fn test_cas_create_tombstone_over_expired() -> Result<()> {
2665 let backend = create_test_backend().await?;
2666
2667 let id = make_id();
2668 let old_lt_id = ObjectId::random(id.context().clone());
2669 let old_tombstone = Tombstone {
2670 target: old_lt_id,
2671 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2672 };
2673 create_tombstone(&backend, &id, &old_tombstone).await?;
2674
2675 let new_lt_id = ObjectId::random(id.context().clone());
2676 let new_tombstone = Tombstone {
2677 target: new_lt_id.clone(),
2678 time_expires: Some(Timestamp::now() + Duration::from_hours(1)),
2679 };
2680 let committed = backend
2681 .compare_and_write(
2682 &id,
2683 None,
2684 TieredWrite::Tombstone(new_tombstone),
2685 Timestamp::now(),
2686 )
2687 .await?;
2688 assert!(
2689 committed,
2690 "CAS with current=None must succeed over an expired tombstone"
2691 );
2692
2693 let TieredMetadata::Tombstone(t) =
2694 backend.get_tiered_metadata(&id, Timestamp::now()).await?
2695 else {
2696 panic!("expected new tombstone to be readable");
2697 };
2698 assert_eq!(t.target, new_lt_id);
2699
2700 Ok(())
2701 }
2702
2703 #[tokio::test]
2706 async fn test_put_non_tombstone_over_expired() -> Result<()> {
2707 let backend = create_test_backend().await?;
2708
2709 let id = make_id();
2710 let lt_id = ObjectId::random(id.context().clone());
2711 let tombstone = Tombstone {
2712 target: lt_id,
2713 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2714 };
2715 create_tombstone(&backend, &id, &tombstone).await?;
2716
2717 let result = backend
2718 .put_non_tombstone(
2719 &id,
2720 &Metadata::default(),
2721 Bytes::from_static(b"data"),
2722 Timestamp::now(),
2723 )
2724 .await?;
2725 assert_eq!(
2726 result, None,
2727 "put_non_tombstone must succeed (return None) over an expired tombstone"
2728 );
2729
2730 let (_, _, stream) = backend
2731 .get_object(&id, Timestamp::now(), None)
2732 .await?
2733 .unwrap();
2734 assert_eq!(&stream::read_to_vec(stream).await?, b"data");
2735
2736 Ok(())
2737 }
2738
2739 async fn put_range_test_object(backend: &BigTableBackend) -> Result<ObjectId> {
2742 let id = make_id();
2743 let metadata = Metadata {
2744 content_type: "text/plain".into(),
2745 ..Default::default()
2746 };
2747 let payload = b"Hello, range requests!";
2748 backend
2749 .put_object(
2750 &id,
2751 &metadata,
2752 stream::single(payload.as_slice()),
2753 Timestamp::now(),
2754 )
2755 .await?;
2756 Ok(id)
2757 }
2758
2759 #[tokio::test]
2760 async fn get_object_range_bounded() -> Result<()> {
2761 let backend = create_test_backend().await?;
2762 let id = put_range_test_object(&backend).await?;
2763
2764 let (_, content_range, stream) = backend
2765 .get_object(&id, Timestamp::now(), Some(ByteRange::Bounded(7, 11)))
2766 .await?
2767 .unwrap();
2768 let data = stream::read_to_vec(stream).await?;
2769 assert_eq!(&data, b"range");
2770
2771 let content_range = content_range.unwrap();
2772 assert_eq!(content_range.start, 7);
2773 assert_eq!(content_range.end, 11);
2774 assert_eq!(content_range.total, 22);
2775
2776 Ok(())
2777 }
2778
2779 #[tokio::test]
2780 async fn get_object_range_from() -> Result<()> {
2781 let backend = create_test_backend().await?;
2782 let id = put_range_test_object(&backend).await?;
2783
2784 let (_, content_range, stream) = backend
2785 .get_object(&id, Timestamp::now(), Some(ByteRange::From(7)))
2786 .await?
2787 .unwrap();
2788 let data = stream::read_to_vec(stream).await?;
2789 assert_eq!(&data, b"range requests!");
2790
2791 let content_range = content_range.unwrap();
2792 assert_eq!(content_range.start, 7);
2793 assert_eq!(content_range.end, 21);
2794 assert_eq!(content_range.total, 22);
2795
2796 Ok(())
2797 }
2798
2799 #[tokio::test]
2800 async fn get_object_range_last() -> Result<()> {
2801 let backend = create_test_backend().await?;
2802 let id = put_range_test_object(&backend).await?;
2803
2804 let (_, content_range, stream) = backend
2805 .get_object(&id, Timestamp::now(), Some(ByteRange::Last(9)))
2806 .await?
2807 .unwrap();
2808 let data = stream::read_to_vec(stream).await?;
2809 assert_eq!(&data, b"requests!");
2810
2811 let content_range = content_range.unwrap();
2812 assert_eq!(content_range.start, 13);
2813 assert_eq!(content_range.end, 21);
2814 assert_eq!(content_range.total, 22);
2815
2816 Ok(())
2817 }
2818
2819 #[tokio::test]
2820 async fn get_object_range_unsatisfiable() -> Result<()> {
2821 let backend = create_test_backend().await?;
2822 let id = put_range_test_object(&backend).await?;
2823
2824 match backend
2825 .get_object(&id, Timestamp::now(), Some(ByteRange::From(100)))
2826 .await
2827 {
2828 Err(error) if matches!(error.kind(), ErrorKind::RangeNotSatisfiable { total: 22 }) => {}
2829 Ok(_) => panic!("expected RangeNotSatisfiable, got Ok"),
2830 Err(e) => panic!("expected RangeNotSatisfiable, got {e:?}"),
2831 }
2832
2833 Ok(())
2834 }
2835
2836 #[tokio::test]
2837 async fn get_object_no_range_returns_full_payload() -> Result<()> {
2838 let backend = create_test_backend().await?;
2839 let id = put_range_test_object(&backend).await?;
2840
2841 let (_, content_range, stream) = backend
2842 .get_object(&id, Timestamp::now(), None)
2843 .await?
2844 .unwrap();
2845 let data = stream::read_to_vec(stream).await?;
2846 assert_eq!(&data, b"Hello, range requests!");
2847 assert!(content_range.is_none());
2848
2849 Ok(())
2850 }
2851
2852 #[test]
2853 fn row_size_counts_the_key_and_every_cell() {
2854 let path = b"attachments/org.1/objects/abc";
2855 let (mutations, size) =
2856 object_mutations(path, Metadata::default(), b"0123456789".to_vec()).unwrap();
2857
2858 let stamped = Metadata {
2862 size: Some(10),
2863 ..Default::default()
2864 };
2865 let metadata_len = serde_json::to_vec(&stamped).unwrap().len();
2866 let expected = (path.len() + 10 + metadata_len) as u64;
2867
2868 assert_eq!(row_size(path, &mutations), expected);
2869 assert_eq!(size, expected, "the size handed back matches the mutations");
2870 }
2871
2872 #[test]
2873 fn row_size_is_nonzero_for_tombstones() {
2874 let path = b"attachments/org.1/objects/abc";
2875 let time_expires = Timestamp::now() + Duration::from_secs(60);
2876 let tombstone = Tombstone {
2877 target: ObjectId::from_storage_path("attachments/org.1/objects/abc/0199").unwrap(),
2878 time_expires: Some(time_expires),
2879 };
2880 let mutations = tombstone_mutations(&tombstone);
2881
2882 assert_eq!(mutations.len(), 2);
2883 let set_cell = mutations[1].mutation.as_ref().unwrap();
2884 let mutation::Mutation::SetCell(set_cell) = set_cell else {
2885 panic!("expected redirect SetCell mutation");
2886 };
2887 assert_eq!(set_cell.family_name, FAMILY_GC);
2888 assert_eq!(set_cell.column_qualifier, COLUMN_REDIRECT);
2889 assert_eq!(set_cell.timestamp_micros, time_expires.as_micros() as i64);
2890 assert!(row_size(path, &mutations) > path.len() as u64);
2891 }
2892
2893 #[cfg(feature = "storage-cogs")]
2894 #[tokio::test]
2895 async fn change_stream_reports_writes_and_deletes() -> Result<()> {
2896 let (backend, producer) = create_test_backend_with_change_stream().await?;
2897 let id = make_id();
2898 let metadata = Metadata {
2899 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(3600)),
2900 time_expires: Some(Timestamp::now() + Duration::from_secs(3600)),
2901 ..Default::default()
2902 };
2903
2904 backend
2905 .put_object(
2906 &id,
2907 &metadata,
2908 stream::single::<crate::stream::ClientError>(b"hello".to_vec()),
2909 Timestamp::now(),
2910 )
2911 .await?;
2912 backend.delete_object(&id, Timestamp::now()).await?;
2913
2914 let records = producer.records();
2915 assert_eq!(records.len(), 2);
2916
2917 assert_eq!(records[0].op_type, OpType::Write);
2918 assert_eq!(records[0].app_feature, "testing");
2919 assert_eq!(records[0].shared_resource_id, "bigtable_objectstore");
2920 assert!(records[0].size.unwrap() > b"hello".len() as u64);
2922 assert!(records[0].expiration_time.is_some());
2923
2924 assert_eq!(records[1].op_type, OpType::Delete);
2925 assert_eq!(records[1].size, None);
2926 assert_eq!(records[1].record_id, records[0].record_id);
2927
2928 Ok(())
2929 }
2930
2931 #[cfg(feature = "storage-cogs")]
2932 #[tokio::test]
2933 async fn delete_non_tombstone_reclaims_expired_rows() -> Result<()> {
2934 let (backend, producer) = create_test_backend_with_change_stream().await?;
2935
2936 let id = make_id();
2939 let tombstone = Tombstone {
2940 target: ObjectId::random(id.context().clone()),
2941 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2942 };
2943 create_tombstone(&backend, &id, &tombstone).await?;
2944 assert_eq!(
2945 backend.delete_non_tombstone(&id, Timestamp::now()).await?,
2946 None,
2947 "an expired tombstone must not be returned to the caller"
2948 );
2949 let records = producer.records();
2950 assert_eq!(records.len(), 1, "the expired tombstone must be reclaimed");
2951 assert_eq!(records[0].op_type, OpType::Delete);
2952
2953 let id = make_id();
2955 let metadata = Metadata {
2956 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
2957 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2958 ..Default::default()
2959 };
2960 create_object(&backend, &id, &metadata, b"gone", Timestamp::now()).await?;
2961 producer.clear();
2962 assert_eq!(
2963 backend.delete_non_tombstone(&id, Timestamp::now()).await?,
2964 None
2965 );
2966 let records = producer.records();
2967 assert_eq!(records.len(), 1, "the object row must be reclaimed");
2968 assert_eq!(records[0].op_type, OpType::Delete);
2969
2970 Ok(())
2971 }
2972
2973 #[cfg(feature = "storage-cogs")]
2974 #[tokio::test]
2975 async fn change_stream_reports_tombstone_rows() -> Result<()> {
2976 let (backend, producer) = create_test_backend_with_change_stream().await?;
2977 let id = make_id();
2978 let target = new_test_revision(&id);
2979
2980 let tombstone = Tombstone {
2981 target: target.clone(),
2982 time_expires: Some(Timestamp::now() + Duration::from_secs(3600)),
2983 };
2984 let written = backend
2985 .compare_and_write(
2986 &id,
2987 None,
2988 TieredWrite::Tombstone(tombstone),
2989 Timestamp::now(),
2990 )
2991 .await?;
2992 assert!(written);
2993
2994 let records = producer.records();
2995 assert_eq!(records.len(), 1);
2996 assert_eq!(records[0].op_type, OpType::Write);
2997 assert!(
2998 records[0].size.unwrap() > 0,
2999 "tombstone rows occupy storage and must not report zero"
3000 );
3001 assert!(records[0].expiration_time.is_some());
3002
3003 Ok(())
3004 }
3005
3006 #[cfg(feature = "storage-cogs")]
3007 #[tokio::test]
3008 async fn change_stream_reports_expiry_extension_as_an_update() -> Result<()> {
3009 let (backend, producer) = create_test_backend_with_change_stream().await?;
3010 let id = make_id();
3011 let metadata = Metadata {
3012 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(3600)),
3013 time_expires: Some(Timestamp::now() + Duration::from_secs(1)),
3014 ..Default::default()
3015 };
3016
3017 backend
3018 .put_object(
3019 &id,
3020 &metadata,
3021 stream::single::<crate::stream::ClientError>(b"hello".to_vec()),
3022 Timestamp::now(),
3023 )
3024 .await?;
3025 producer.clear();
3026
3027 backend
3028 .set_expiry(
3029 &id,
3030 Timestamp::now() + Duration::from_secs(3600),
3031 Timestamp::now(),
3032 )
3033 .await?;
3034
3035 let records = producer.records();
3036 assert_eq!(records.len(), 1, "expected exactly one extension report");
3037 assert_eq!(records[0].op_type, OpType::Update);
3038 assert_eq!(
3039 records[0].size, None,
3040 "an extension does not change the size"
3041 );
3042 assert!(records[0].expiration_time.is_some());
3043
3044 Ok(())
3045 }
3046
3047 #[cfg(feature = "storage-cogs")]
3048 fn new_test_revision(id: &ObjectId) -> ObjectId {
3049 ObjectId {
3050 context: id.context.clone(),
3051 key: format!("{}/{}", id.key, uuid::Uuid::now_v7()),
3052 }
3053 }
3054}