1use std::fmt;
36use std::future::Future;
37use std::sync::Arc;
38use std::time::Duration;
39
40use bigtable_rs::bigtable::{BigTableConnection, Error as BigTableError, RowCell};
41use bigtable_rs::google::bigtable::v2::{self, mutation};
42use bytes::Bytes;
43use futures_util::TryStreamExt;
44use objectstore_types::metadata::Metadata;
45use objectstore_types::range::{ByteRange, ContentRange};
46use objectstore_types::time::Timestamp;
47use serde::{Deserialize, Serialize};
48use tonic::Code;
49use tracing::Instrument;
50
51use crate::backend::common::{
52 self, Backend, DeleteResponse, ExpiryUpdate, GetResponse, HighVolumeBackend, MetadataResponse,
53 PutResponse, SetExpiryResponse, TieredGet, TieredMetadata, TieredUpdate, TieredWrite,
54 Tombstone,
55};
56use crate::change_stream::{
57 ChangeStream, ChangeStreamFactory, CostTrackerStreamConfig, flush_change_stream,
58};
59use crate::error::{Error, ErrorKind, Result, ResultExt as _};
60use crate::gcp_auth::PrefetchingTokenProvider;
61use crate::id::ObjectId;
62use crate::stream::{ChunkedBytes, ClientStream};
63
64#[derive(Debug, Clone, Deserialize, Serialize)]
86pub struct BigTableConfig {
87 pub endpoint: Option<String>,
100
101 pub project_id: String,
109
110 pub instance_name: String,
116
117 pub table_name: String,
125
126 pub connections: Option<usize>,
136
137 #[serde(default = "default_rpc_timeout", with = "humantime_serde")]
148 pub rpc_timeout: Duration,
149
150 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub cogs: Option<CostTrackerStreamConfig>,
162}
163
164fn default_rpc_timeout() -> Duration {
165 Duration::from_secs(2)
166}
167
168const MAX_CHANNEL_AGE: Option<Duration> = Some(Duration::from_mins(50));
176const TOKEN_SCOPES: &[&str] = &["https://www.googleapis.com/auth/bigtable.data"];
178
179const REQUEST_RETRY_COUNT: usize = 2;
181const CAS_RETRY_COUNT: usize = 3;
183
184const COLUMN_PAYLOAD: &[u8] = b"p";
186const COLUMN_METADATA: &[u8] = b"m";
188const COLUMN_REDIRECT: &[u8] = b"r";
190const FILTER_META: &[u8] = b"^[mr]$";
192
193const COLUMN_UPLOAD: &[u8] = b"u";
195
196const FAMILY_GC: &str = "fg";
201const FAMILY_MANUAL: &str = "fm";
203
204pub struct BigTableBackend {
206 bigtable: BigTableConnection,
207
208 instance_path: String,
209 table_path: String,
210 table_name: String,
211
212 change_stream: Arc<dyn ChangeStream>,
213}
214
215impl fmt::Debug for BigTableBackend {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 f.debug_struct("BigTableBackend")
218 .field("instance_path", &self.instance_path)
219 .field("table_path", &self.table_path)
220 .field("table_name", &self.table_name)
221 .finish_non_exhaustive()
222 }
223}
224
225fn column_filter(column: &[u8]) -> v2::RowFilter {
227 v2::RowFilter {
228 filter: Some(v2::row_filter::Filter::ColumnQualifierRegexFilter(
229 [b"^", column, b"$"].concat(),
230 )),
231 }
232}
233
234fn legacy_tombstone_filter() -> v2::RowFilter {
239 v2::RowFilter {
240 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
241 filters: vec![
242 column_filter(COLUMN_METADATA),
243 v2::RowFilter {
244 filter: Some(v2::row_filter::Filter::ValueRegexFilter(
245 b"^\\{\"is_redirect_tombstone\":true[,}].*".to_vec(),
246 )),
247 },
248 ],
249 })),
250 }
251}
252
253fn live_row_filter(inner: v2::RowFilter, now: Timestamp) -> v2::RowFilter {
258 v2::RowFilter {
259 filter: Some(v2::row_filter::Filter::Interleave(
260 v2::row_filter::Interleave {
261 filters: vec![
262 v2::RowFilter {
264 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
265 filters: vec![
266 v2::RowFilter {
267 filter: Some(v2::row_filter::Filter::FamilyNameRegexFilter(
268 format!("^{FAMILY_MANUAL}$"),
269 )),
270 },
271 inner.clone(),
272 ],
273 })),
274 },
275 v2::RowFilter {
277 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
278 filters: vec![
279 v2::RowFilter {
280 filter: Some(v2::row_filter::Filter::FamilyNameRegexFilter(
281 format!("^{FAMILY_GC}$"),
282 )),
283 },
284 v2::RowFilter {
285 filter: Some(v2::row_filter::Filter::TimestampRangeFilter(
286 v2::TimestampRange {
287 start_timestamp_micros: now.as_micros() as i64,
288 end_timestamp_micros: 0,
289 },
290 )),
291 },
292 inner,
293 ],
294 })),
295 },
296 ],
297 },
298 )),
299 }
300}
301
302fn tombstone_filter(access_time: Timestamp) -> v2::RowFilter {
310 let filter = v2::RowFilter {
311 filter: Some(v2::row_filter::Filter::Interleave(
312 v2::row_filter::Interleave {
313 filters: vec![column_filter(COLUMN_REDIRECT), legacy_tombstone_filter()],
314 },
315 )),
316 };
317 live_row_filter(filter, access_time)
318}
319
320fn tombstone_predicate(access_time: Timestamp) -> MutatePredicate {
330 MutatePredicate::Exclude(tombstone_filter(access_time))
331}
332
333fn non_tombstone_predicate(access_time: Timestamp) -> MutatePredicate {
346 MutatePredicate::Include(v2::RowFilter {
347 filter: Some(v2::row_filter::Filter::Condition(Box::new(
348 v2::row_filter::Condition {
349 predicate_filter: Some(Box::new(tombstone_filter(access_time))),
350 true_filter: Some(Box::new(v2::RowFilter {
351 filter: Some(v2::row_filter::Filter::BlockAllFilter(true)),
352 })),
353 false_filter: Some(Box::new(v2::RowFilter {
354 filter: Some(v2::row_filter::Filter::PassAllFilter(true)),
355 })),
356 },
357 ))),
358 })
359}
360
361fn exact_value_regex(value: &str) -> Vec<u8> {
366 format!("^{}$", regex::escape(value)).into_bytes()
367}
368
369fn redirect_target_filter(
387 target: &ObjectId,
388 own_id: &ObjectId,
389 access_time: Timestamp,
390) -> v2::RowFilter {
391 let target_path = exact_value_regex(&target.as_storage_path().to_string());
392
393 let exact_match = v2::RowFilter {
394 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
395 filters: vec![
396 column_filter(COLUMN_REDIRECT),
397 v2::RowFilter {
398 filter: Some(v2::row_filter::Filter::ValueRegexFilter(target_path)),
399 },
400 ],
401 })),
402 };
403
404 if target != own_id {
405 return live_row_filter(exact_match, access_time);
406 }
407
408 let empty_redirect_match = v2::RowFilter {
409 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
410 filters: vec![
411 column_filter(COLUMN_REDIRECT),
412 v2::RowFilter {
413 filter: Some(v2::row_filter::Filter::ValueRegexFilter(b"^$".to_vec())),
414 },
415 ],
416 })),
417 };
418
419 let filter = v2::RowFilter {
423 filter: Some(v2::row_filter::Filter::Interleave(
424 v2::row_filter::Interleave {
425 filters: vec![exact_match, empty_redirect_match, legacy_tombstone_filter()],
426 },
427 )),
428 };
429 live_row_filter(filter, access_time)
430}
431
432fn update_predicate(
439 old: &ObjectId,
440 new: &ObjectId,
441 own_id: &ObjectId,
442 access_time: Timestamp,
443) -> MutatePredicate {
444 MutatePredicate::Include(v2::RowFilter {
445 filter: Some(v2::row_filter::Filter::Interleave(
446 v2::row_filter::Interleave {
447 filters: vec![
448 redirect_target_filter(old, own_id, access_time),
449 redirect_target_filter(new, own_id, access_time),
450 ],
451 },
452 )),
453 })
454}
455
456fn optional_target_predicate(
468 target: &ObjectId,
469 own_id: &ObjectId,
470 access_time: Timestamp,
471) -> MutatePredicate {
472 MutatePredicate::Exclude(v2::RowFilter {
473 filter: Some(v2::row_filter::Filter::Condition(Box::new(
474 v2::row_filter::Condition {
475 predicate_filter: Some(Box::new(redirect_target_filter(
476 target,
477 own_id,
478 access_time,
479 ))),
480 true_filter: Some(Box::new(v2::RowFilter {
481 filter: Some(v2::row_filter::Filter::BlockAllFilter(true)),
482 })),
483 false_filter: Some(Box::new(tombstone_filter(access_time))),
484 },
485 ))),
486 })
487}
488
489fn exact_expiry_filter(start: i64) -> Result<v2::RowFilter> {
490 let end = start.checked_add(1).ok_or_else(|| {
491 Error::new(
492 ErrorKind::Internal,
493 "building Bigtable expiration predicate",
494 )
495 })?;
496 Ok(v2::RowFilter {
497 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
498 filters: vec![
499 v2::RowFilter {
500 filter: Some(v2::row_filter::Filter::FamilyNameRegexFilter(format!(
501 "^{FAMILY_GC}$"
502 ))),
503 },
504 v2::RowFilter {
505 filter: Some(v2::row_filter::Filter::TimestampRangeFilter(
506 v2::TimestampRange {
507 start_timestamp_micros: start,
508 end_timestamp_micros: end,
509 },
510 )),
511 },
512 ],
513 })),
514 })
515}
516
517fn inline_expiry_predicate(
519 observed_expiry: i64,
520 access_time: Timestamp,
521) -> Result<MutatePredicate> {
522 let inline_at_expiry = v2::RowFilter {
523 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
524 filters: vec![
525 column_filter(COLUMN_METADATA),
526 exact_expiry_filter(observed_expiry)?,
527 ],
528 })),
529 };
530
531 Ok(MutatePredicate::Include(v2::RowFilter {
532 filter: Some(v2::row_filter::Filter::Condition(Box::new(
533 v2::row_filter::Condition {
534 predicate_filter: Some(Box::new(tombstone_filter(access_time))),
535 true_filter: Some(Box::new(v2::RowFilter {
536 filter: Some(v2::row_filter::Filter::BlockAllFilter(true)),
537 })),
538 false_filter: Some(Box::new(inline_at_expiry)),
539 },
540 ))),
541 }))
542}
543
544fn redirect_expiry_predicate(
545 target: &ObjectId,
546 own_id: &ObjectId,
547 observed_expiry: i64,
548 access_time: Timestamp,
549) -> Result<MutatePredicate> {
550 Ok(MutatePredicate::Include(v2::RowFilter {
551 filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
552 filters: vec![
553 redirect_target_filter(target, own_id, access_time),
554 exact_expiry_filter(observed_expiry)?,
555 ],
556 })),
557 }))
558}
559
560#[derive(Clone, Debug)]
565enum MutatePredicate {
566 Include(v2::RowFilter),
570 Exclude(v2::RowFilter),
574}
575
576fn metadata_filter() -> v2::RowFilter {
581 v2::RowFilter {
582 filter: Some(v2::row_filter::Filter::ColumnQualifierRegexFilter(
583 FILTER_META.to_owned(),
584 )),
585 }
586}
587
588fn mutation(mutation: mutation::Mutation) -> v2::Mutation {
589 v2::Mutation {
590 mutation: Some(mutation),
591 }
592}
593
594fn delete_row_mutation() -> v2::Mutation {
596 mutation(mutation::Mutation::DeleteFromRow(
597 mutation::DeleteFromRow {},
598 ))
599}
600
601fn object_mutations(
607 path: &[u8],
608 mut metadata: Metadata,
609 payload: Vec<u8>,
610) -> Result<([v2::Mutation; 3], u64)> {
611 let (family, timestamp_micros) = match metadata.time_expires {
612 None => (FAMILY_MANUAL, -1),
613 Some(deadline) => (FAMILY_GC, deadline.as_micros() as i64),
614 };
615
616 metadata.size = Some(payload.len());
618
619 let metadata_bytes = serde_json::to_vec(&metadata)
620 .context(ErrorKind::Internal, "encoding Bigtable object metadata")?;
621
622 let mutations = [
623 delete_row_mutation(),
625 mutation(mutation::Mutation::SetCell(mutation::SetCell {
626 family_name: family.to_owned(),
627 column_qualifier: COLUMN_PAYLOAD.to_owned(),
628 timestamp_micros,
629 value: payload,
630 })),
631 mutation(mutation::Mutation::SetCell(mutation::SetCell {
632 family_name: family.to_owned(),
633 column_qualifier: COLUMN_METADATA.to_owned(),
634 timestamp_micros,
635 value: metadata_bytes,
636 })),
637 ];
638
639 let size = row_size(path, &mutations);
640 Ok((mutations, size))
641}
642
643fn row_size(path: &[u8], mutations: &[v2::Mutation]) -> u64 {
648 let cells: usize = mutations
649 .iter()
650 .filter_map(|m| match &m.mutation {
651 Some(mutation::Mutation::SetCell(cell)) => Some(cell.value.len()),
652 _ => None,
653 })
654 .sum();
655
656 (path.len() + cells) as u64
657}
658
659fn tombstone_mutations(tombstone: &Tombstone) -> [v2::Mutation; 2] {
664 let (family, timestamp_micros) = match tombstone.time_expires {
665 None => (FAMILY_MANUAL, -1),
666 Some(deadline) => (FAMILY_GC, deadline.as_micros() as i64),
667 };
668
669 [
670 delete_row_mutation(),
671 mutation(mutation::Mutation::SetCell(mutation::SetCell {
672 family_name: family.to_owned(),
673 column_qualifier: COLUMN_REDIRECT.to_owned(),
674 timestamp_micros,
675 value: tombstone.target.as_storage_path().to_string().into_bytes(),
676 })),
677 ]
678}
679
680#[derive(Debug, Deserialize)]
684struct LegacyTombstoneMeta {
685 #[serde(default)]
692 is_redirect_tombstone: bool,
693}
694
695enum RowData {
697 Object {
699 metadata: Metadata,
700 payload: Vec<u8>,
701 expiry_micros: i64,
703 },
704 Tombstone {
706 target: Vec<u8>,
707 time_expires: Option<Timestamp>,
708 expiry_micros: i64,
710 },
711}
712
713impl RowData {
714 fn from_cells(cells: Vec<RowCell>) -> Result<Self> {
721 let mut metadata_opt: Option<Metadata> = None;
722 let mut redirect_detected = false;
723 let mut redirect_target = Vec::new();
724 let mut expire_at = None;
725 let mut expiry_micros = 0;
726 let mut payload = Vec::new();
727
728 for cell in cells {
729 if cell.family_name == FAMILY_GC {
734 expiry_micros = cell.timestamp_micros;
735 expire_at = Some(Timestamp::from_unix_micros(expiry_micros).context(
736 ErrorKind::CorruptData,
737 "decoding Bigtable expiration timestamp",
738 )?);
739 }
740
741 match cell.qualifier.as_slice() {
742 COLUMN_REDIRECT => {
743 redirect_detected = true;
744 redirect_target = cell.value;
745 }
746 COLUMN_PAYLOAD => {
747 payload = cell.value;
748 }
749 COLUMN_METADATA => {
750 if let Ok(legacy_meta) =
751 serde_json::from_slice::<LegacyTombstoneMeta>(&cell.value)
752 && legacy_meta.is_redirect_tombstone
753 {
754 redirect_detected = true;
755 objectstore_metrics::count!("bigtable.legacy_tombstone_read");
756 } else {
757 metadata_opt = Some(serde_json::from_slice(&cell.value).context(
758 ErrorKind::CorruptData,
759 "decoding Bigtable object metadata",
760 )?);
761 }
762 }
763 _ => {}
764 }
765 }
766
767 Ok(if redirect_detected {
768 RowData::Tombstone {
769 target: redirect_target,
770 time_expires: expire_at,
771 expiry_micros,
772 }
773 } else {
774 let mut metadata = metadata_opt.unwrap_or_default();
776 metadata.time_expires = expire_at;
777 RowData::Object {
778 metadata,
779 payload,
780 expiry_micros,
781 }
782 })
783 }
784
785 fn time_expires(&self) -> Option<Timestamp> {
787 match self {
788 RowData::Object { metadata, .. } => metadata.time_expires,
789 RowData::Tombstone { time_expires, .. } => *time_expires,
790 }
791 }
792
793 fn expires_before(&self, time: Timestamp) -> bool {
797 self.time_expires().is_some_and(|ts| ts < time)
798 }
799}
800
801fn parse_redirect_target(redirect_path: &[u8], tombstone_id: &ObjectId) -> Result<ObjectId> {
807 if redirect_path.is_empty() {
808 objectstore_metrics::count!("bigtable.empty_redirect_read");
809 Ok(tombstone_id.clone())
810 } else {
811 let redirect_str = std::str::from_utf8(redirect_path)
812 .context(ErrorKind::CorruptData, "decoding Bigtable redirect target")?;
813 ObjectId::from_storage_path(redirect_str)
814 .ok_or_else(|| Error::new(ErrorKind::CorruptData, "parsing Bigtable redirect target"))
815 }
816}
817
818impl BigTableBackend {
819 pub async fn new(
826 config: BigTableConfig,
827 streams: &ChangeStreamFactory,
828 ) -> anyhow::Result<Self> {
829 let BigTableConfig {
830 endpoint,
831 project_id,
832 instance_name,
833 table_name,
834 connections,
835 rpc_timeout,
836 cogs,
837 } = config;
838 let change_stream = streams.build(cogs.as_ref());
839
840 let bigtable = if let Some(ref endpoint) = endpoint {
841 BigTableConnection::new_with_emulator(
842 endpoint,
843 &project_id,
844 &instance_name,
845 false, connections.unwrap_or(1),
847 Some(rpc_timeout),
848 )?
849 } else {
850 let token_provider = PrefetchingTokenProvider::gcp_auth(TOKEN_SCOPES).await?;
851 BigTableConnection::new_with_managed_transport(
852 &project_id,
853 &instance_name,
854 false, Some(rpc_timeout),
856 Arc::new(token_provider),
857 connections.unwrap_or(1),
858 true, None, MAX_CHANNEL_AGE,
861 Some(Duration::from_secs(10)), )
863 .await?
864 };
865
866 let client = bigtable.client();
867
868 Ok(Self {
869 bigtable,
870 instance_path: format!("projects/{project_id}/instances/{instance_name}"),
871 table_path: client.get_full_table_name(&table_name),
872 table_name,
873 change_stream,
874 })
875 }
876
877 #[tracing::instrument(level = "debug", fields(action), skip_all)]
881 async fn read_row(
882 &self,
883 path: &[u8],
884 action: &'static str,
885 access_time: Timestamp,
886 filter: Option<v2::RowFilter>,
887 ) -> Result<Option<RowData>> {
888 let request = v2::ReadRowsRequest {
889 table_name: self.table_path.clone(),
890 rows: Some(v2::RowSet {
891 row_keys: vec![path.to_owned()],
892 row_ranges: vec![],
893 }),
894 filter,
895 rows_limit: 1,
896 ..Default::default()
897 };
898
899 let response = retry(action, || async {
900 self.bigtable.client().read_rows(request.clone()).await
901 })
902 .await?;
903 debug_assert!(response.len() <= 1, "Expected at most one row");
904
905 let Some((_, cells)) = response.into_iter().next() else {
906 objectstore_log::debug!("Object not found");
907 return Ok(None);
908 };
909
910 let row = RowData::from_cells(cells)?;
911 Ok(if row.expires_before(access_time) {
912 None
913 } else {
914 Some(row)
915 })
916 }
917
918 #[tracing::instrument(level = "debug", fields(action), skip_all)]
919 async fn mutate(
920 &self,
921 path: Vec<u8>,
922 mutations: impl Into<Vec<v2::Mutation>>,
923 action: &'static str,
924 ) -> Result<v2::MutateRowResponse> {
925 let request = v2::MutateRowRequest {
926 table_name: self.table_path.clone(),
927 row_key: path,
928 mutations: mutations.into(),
929 ..Default::default()
930 };
931
932 let response = retry(action, || async {
933 self.bigtable.client().mutate_row(request.clone()).await
934 })
935 .await?;
936
937 Ok(response.into_inner())
938 }
939
940 async fn put_row(
942 &self,
943 path: Vec<u8>,
944 metadata: Metadata,
945 payload: Vec<u8>,
946 action: &'static str,
947 ) -> Result<(v2::MutateRowResponse, u64)> {
948 let (mutations, size) = object_mutations(&path, metadata, payload)?;
949 let response = self.mutate(path, mutations, action).await?;
950 Ok((response, size))
951 }
952
953 #[tracing::instrument(level = "debug", fields(action = context), skip_all)]
955 async fn check_and_mutate(
956 &self,
957 row_key: Vec<u8>,
958 predicate: MutatePredicate,
959 mutations: impl Into<Vec<v2::Mutation>>,
960 context: &'static str,
961 ) -> Result<bool> {
962 let (filter, true_mutations, false_mutations, success_on_match) = match predicate {
963 MutatePredicate::Include(f) => (f, mutations.into(), vec![], true),
964 MutatePredicate::Exclude(f) => (f, vec![], mutations.into(), false),
965 };
966
967 let request = v2::CheckAndMutateRowRequest {
968 table_name: self.table_path.clone(),
969 row_key,
970 predicate_filter: Some(filter),
971 true_mutations,
972 false_mutations,
973 ..Default::default()
974 };
975
976 let future = retry(context, || async {
977 self.bigtable
978 .client()
979 .check_and_mutate_row(request.clone())
980 .await
981 });
982
983 Ok(future.await?.predicate_matched == success_on_match)
984 }
985}
986
987#[async_trait::async_trait]
988impl Backend for BigTableBackend {
989 fn name(&self) -> &'static str {
990 "bigtable"
991 }
992
993 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
994 async fn put_object(
995 &self,
996 id: &ObjectId,
997 metadata: &Metadata,
998 mut stream: ClientStream,
999 _access_time: Timestamp,
1000 ) -> Result<PutResponse> {
1001 objectstore_log::debug!("Writing to Bigtable backend");
1002 self.change_stream
1003 .begin_write(id, metadata.time_expires)
1004 .await?;
1005 let path = id.as_storage_path().to_string().into_bytes();
1006
1007 let mut payload = ChunkedBytes::new(0);
1008 while let Some(chunk) = stream.try_next().await? {
1009 payload.push(chunk);
1010 }
1011
1012 let (_, size) = self
1013 .put_row(path, metadata.clone(), payload.into_bytes().into(), "put")
1014 .await?;
1015 self.change_stream
1016 .commit_write(id, size, metadata.time_expires)
1017 .await;
1018
1019 Ok(())
1020 }
1021
1022 #[tracing::instrument(level = "debug", skip(self))]
1023 async fn get_object(
1024 &self,
1025 id: &ObjectId,
1026 access_time: Timestamp,
1027 range: Option<ByteRange>,
1028 ) -> Result<GetResponse> {
1029 match self.get_tiered_object(id, access_time, range).await? {
1030 TieredGet::Object(metadata, content_range, payload) => {
1031 Ok(Some((metadata, content_range, payload)))
1032 }
1033 TieredGet::Tombstone(_) => Err(ErrorKind::UnexpectedTombstone.into()),
1034 TieredGet::NotFound => Ok(None),
1035 }
1036 }
1037
1038 #[tracing::instrument(level = "debug", skip(self))]
1039 async fn get_metadata(
1040 &self,
1041 id: &ObjectId,
1042 access_time: Timestamp,
1043 ) -> Result<MetadataResponse> {
1044 match self.get_tiered_metadata(id, access_time).await? {
1045 TieredMetadata::Object(metadata) => Ok(Some(metadata)),
1046 TieredMetadata::Tombstone(_) => Err(ErrorKind::UnexpectedTombstone.into()),
1047 TieredMetadata::NotFound => Ok(None),
1048 }
1049 }
1050
1051 async fn set_expiry(
1052 &self,
1053 id: &ObjectId,
1054 target: ExpiryUpdate,
1055 access_time: Timestamp,
1056 ) -> Result<SetExpiryResponse> {
1057 self.compare_and_update(id, None, TieredUpdate::SetExpiry(target), access_time)
1058 .await
1059 }
1060
1061 #[tracing::instrument(level = "debug", skip(self))]
1062 async fn delete_object(
1063 &self,
1064 id: &ObjectId,
1065 _access_time: Timestamp,
1066 ) -> Result<DeleteResponse> {
1067 objectstore_log::debug!("Deleting from Bigtable backend");
1068
1069 let path = id.as_storage_path().to_string().into_bytes();
1070 self.mutate(path, [delete_row_mutation()], "delete").await?;
1071 self.change_stream.commit_delete(id).await;
1072
1073 Ok(())
1074 }
1075
1076 async fn join(&self) {
1077 flush_change_stream(&self.change_stream).await;
1078 }
1079}
1080
1081#[async_trait::async_trait]
1082impl HighVolumeBackend for BigTableBackend {
1083 async fn create_upload_marker(
1084 &self,
1085 revision: &ObjectId,
1086 time_expires: Timestamp,
1087 ) -> Result<()> {
1088 self.change_stream
1089 .begin_write(revision, Some(time_expires))
1090 .await?;
1091 let mutations = vec![mutation(mutation::Mutation::SetCell(mutation::SetCell {
1092 family_name: FAMILY_GC.to_owned(),
1093 column_qualifier: COLUMN_UPLOAD.to_vec(),
1094 timestamp_micros: time_expires.as_micros() as i64,
1095 value: vec![1],
1096 }))];
1097 let path = revision.as_upload_path().to_string().into_bytes();
1098 let size = row_size(&path, &mutations);
1099 self.mutate(path, mutations, "create_upload_marker").await?;
1100 self.change_stream
1101 .commit_write(revision, size, Some(time_expires))
1102 .await;
1103 Ok(())
1104 }
1105
1106 async fn has_upload_marker(&self, revision: &ObjectId, access_time: Timestamp) -> Result<bool> {
1107 let request = v2::ReadRowsRequest {
1108 table_name: self.table_path.clone(),
1109 rows: Some(v2::RowSet {
1110 row_keys: vec![revision.as_upload_path().to_string().into_bytes()],
1111 row_ranges: vec![],
1112 }),
1113 filter: Some(live_row_filter(column_filter(COLUMN_UPLOAD), access_time)),
1114 rows_limit: 1,
1115 ..Default::default()
1116 };
1117 let rows = retry("has_upload_marker", || async {
1118 self.bigtable.client().read_rows(request.clone()).await
1119 })
1120 .await?;
1121 Ok(!rows.is_empty())
1122 }
1123
1124 async fn delete_upload_marker(
1125 &self,
1126 revision: &ObjectId,
1127 access_time: Timestamp,
1128 ) -> Result<bool> {
1129 let deleted = self
1130 .check_and_mutate(
1131 revision.as_upload_path().to_string().into_bytes(),
1132 MutatePredicate::Include(live_row_filter(
1133 column_filter(COLUMN_UPLOAD),
1134 access_time,
1135 )),
1136 vec![delete_row_mutation()],
1137 "delete_upload_marker",
1138 )
1139 .await?;
1140 if deleted {
1141 self.change_stream.commit_delete(revision).await;
1142 }
1143 Ok(deleted)
1144 }
1145
1146 #[tracing::instrument(level = "debug", fields(?id), skip_all)]
1147 async fn put_non_tombstone(
1148 &self,
1149 id: &ObjectId,
1150 metadata: &Metadata,
1151 payload: Bytes,
1152 access_time: Timestamp,
1153 ) -> Result<Option<Tombstone>> {
1154 objectstore_log::debug!("Conditional put to Bigtable backend");
1155 self.change_stream
1156 .begin_write(id, metadata.time_expires)
1157 .await?;
1158
1159 let path = id.as_storage_path().to_string().into_bytes();
1160 let (mutations, size) = object_mutations(&path, metadata.clone(), payload.to_vec())?;
1161
1162 for _ in 0..CAS_RETRY_COUNT {
1163 let write_succeeded = self
1164 .check_and_mutate(
1165 path.clone(),
1166 tombstone_predicate(access_time),
1167 mutations.clone(),
1168 "put_non_tombstone",
1169 )
1170 .await?;
1171
1172 if write_succeeded {
1173 self.change_stream
1174 .commit_write(id, size, metadata.time_expires)
1175 .await;
1176 return Ok(None);
1177 }
1178
1179 let row = self
1181 .read_row(
1182 &path,
1183 "put_non_tombstone",
1184 access_time,
1185 Some(metadata_filter()),
1186 )
1187 .await?;
1188
1189 match row {
1190 Some(RowData::Tombstone {
1191 target,
1192 time_expires,
1193 ..
1194 }) => {
1195 return Ok(Some(Tombstone {
1196 target: parse_redirect_target(&target, id)?,
1197 time_expires,
1198 }));
1199 }
1200 Some(RowData::Object { .. }) => continue,
1202 None => continue,
1204 }
1205 }
1206
1207 Err(Error::new(
1208 ErrorKind::Internal,
1209 "Bigtable put race exhausted",
1210 ))
1211 }
1212
1213 #[tracing::instrument(level = "debug", skip(self))]
1214 async fn get_tiered_object(
1215 &self,
1216 id: &ObjectId,
1217 access_time: Timestamp,
1218 range: Option<ByteRange>,
1219 ) -> Result<TieredGet> {
1220 objectstore_log::debug!("Reading from Bigtable backend");
1221 let path = id.as_storage_path().to_string().into_bytes();
1222
1223 let Some(row) = self
1224 .read_row(&path, "get_tiered_object", access_time, None)
1225 .await?
1226 else {
1227 return Ok(TieredGet::NotFound);
1228 };
1229
1230 Ok(match row {
1231 RowData::Tombstone {
1232 target,
1233 time_expires,
1234 ..
1235 } => TieredGet::Tombstone(Tombstone {
1236 target: parse_redirect_target(&target, id)?,
1237 time_expires,
1238 }),
1239 RowData::Object {
1240 metadata, payload, ..
1241 } => {
1242 let mut metadata = metadata;
1243 let payload = Bytes::from(payload);
1244 if metadata.size.is_none() {
1245 metadata.size = Some(payload.len());
1247 }
1248
1249 let (content_range, payload) = apply_range(payload, range)?;
1250 TieredGet::Object(metadata, content_range, crate::stream::single(payload))
1251 }
1252 })
1253 }
1254
1255 #[tracing::instrument(level = "debug", skip(self))]
1256 async fn get_tiered_metadata(
1257 &self,
1258 id: &ObjectId,
1259 access_time: Timestamp,
1260 ) -> Result<TieredMetadata> {
1261 objectstore_log::debug!("Reading metadata from Bigtable backend");
1262 let path = id.as_storage_path().to_string().into_bytes();
1263
1264 let row_opt = self
1268 .read_row(
1269 &path,
1270 "get_tiered_metadata",
1271 access_time,
1272 Some(metadata_filter()),
1273 )
1274 .await?;
1275 let Some(row) = row_opt else {
1276 return Ok(TieredMetadata::NotFound);
1277 };
1278
1279 Ok(match row {
1280 RowData::Tombstone {
1281 target,
1282 time_expires,
1283 ..
1284 } => TieredMetadata::Tombstone(Tombstone {
1285 target: parse_redirect_target(&target, id)?,
1286 time_expires,
1287 }),
1288 RowData::Object { metadata, .. } => TieredMetadata::Object(metadata),
1289 })
1290 }
1291
1292 #[tracing::instrument(level = "debug", skip(self))]
1293 async fn compare_and_update(
1294 &self,
1295 id: &ObjectId,
1296 current: Option<&ObjectId>,
1297 update: TieredUpdate,
1298 access_time: Timestamp,
1299 ) -> Result<SetExpiryResponse> {
1300 let TieredUpdate::SetExpiry(expiry_target) = update;
1301 let path = id.as_storage_path().to_string().into_bytes();
1302
1303 let Some(row) = self
1306 .read_row(&path, "set_expiry", access_time, None)
1307 .await?
1308 else {
1309 return Ok(SetExpiryResponse::NotFound);
1310 };
1311
1312 let (expire_at, predicate, mutations): (_, _, Vec<_>) = match row {
1313 RowData::Object {
1314 metadata,
1315 payload,
1316 expiry_micros,
1317 } => {
1318 if current.is_some() {
1319 return Ok(SetExpiryResponse::Rejected); }
1321 let Some(old_expiry) = metadata.time_expires else {
1322 return Ok(SetExpiryResponse::Rejected);
1323 };
1324
1325 if old_expiry < access_time {
1326 return Ok(SetExpiryResponse::NotFound); }
1328 let Some(expire_at) = expiry_target.resolve(metadata.time_created, access_time)?
1329 else {
1330 return Ok(SetExpiryResponse::Rejected);
1331 };
1332 if old_expiry >= expire_at {
1333 return Ok(SetExpiryResponse::Satisfied(expire_at)); }
1335
1336 let predicate = inline_expiry_predicate(expiry_micros, access_time)?;
1340 let mut metadata = metadata;
1341 metadata.expiration_policy = common::extended_expiration_policy(
1342 metadata.expiration_policy,
1343 metadata.time_created,
1344 old_expiry,
1345 expire_at,
1346 )?;
1347 metadata.time_expires = Some(expire_at);
1348 let (mutations, _) = object_mutations(&path, metadata, payload)?;
1349 (expire_at, predicate, mutations.into())
1350 }
1351 RowData::Tombstone {
1352 target,
1353 time_expires,
1354 expiry_micros,
1355 } => {
1356 let Some(expected) = current else {
1357 return Ok(SetExpiryResponse::Rejected); };
1359 let Some(old_expiry) = time_expires else {
1360 return Ok(SetExpiryResponse::Rejected);
1361 };
1362
1363 let redirect_target = parse_redirect_target(&target, id)?;
1364 if old_expiry < access_time {
1365 return Ok(SetExpiryResponse::NotFound);
1366 }
1367 if redirect_target != *expected {
1368 return Ok(SetExpiryResponse::Rejected); }
1370 let Some(expire_at) = expiry_target.resolve(None, access_time)? else {
1371 return Ok(SetExpiryResponse::Rejected);
1372 };
1373 if old_expiry >= expire_at {
1374 return Ok(SetExpiryResponse::Satisfied(expire_at)); }
1376
1377 let predicate =
1378 redirect_expiry_predicate(expected, id, expiry_micros, access_time)?;
1379 let tombstone = Tombstone {
1380 target: redirect_target,
1381 time_expires: Some(expire_at),
1382 };
1383 (expire_at, predicate, tombstone_mutations(&tombstone).into())
1384 }
1385 };
1386
1387 self.change_stream.begin_update(id, Some(expire_at)).await?;
1388 let applied = self
1389 .check_and_mutate(path, predicate, mutations, "set_expiry")
1390 .await?;
1391
1392 if applied {
1393 self.change_stream.commit_update(id, Some(expire_at)).await;
1394 }
1395
1396 Ok(if applied {
1397 SetExpiryResponse::Satisfied(expire_at)
1398 } else {
1399 SetExpiryResponse::Rejected
1400 })
1401 }
1402
1403 #[tracing::instrument(level = "debug", skip(self))]
1404 async fn delete_non_tombstone(
1405 &self,
1406 id: &ObjectId,
1407 access_time: Timestamp,
1408 ) -> Result<Option<Tombstone>> {
1409 objectstore_log::debug!("Conditional delete from Bigtable backend");
1410
1411 let path = id.as_storage_path().to_string().into_bytes();
1412
1413 for _ in 0..CAS_RETRY_COUNT {
1414 let deleted = self
1415 .check_and_mutate(
1416 path.clone(),
1417 non_tombstone_predicate(access_time),
1418 [delete_row_mutation()],
1419 "delete_non_tombstone",
1420 )
1421 .await?;
1422
1423 if deleted {
1424 self.change_stream.commit_delete(id).await;
1425 return Ok(None);
1426 }
1427
1428 let row = self
1431 .read_row(
1432 &path,
1433 "delete_non_tombstone",
1434 access_time,
1435 Some(metadata_filter()),
1436 )
1437 .await?;
1438
1439 match row {
1440 Some(RowData::Tombstone {
1441 target,
1442 time_expires,
1443 ..
1444 }) => {
1445 return Ok(Some(Tombstone {
1446 target: parse_redirect_target(&target, id)?,
1447 time_expires,
1448 }));
1449 }
1450 Some(RowData::Object { .. }) => continue,
1452 None => return Ok(None),
1454 }
1455 }
1456
1457 Err(Error::new(
1458 ErrorKind::Internal,
1459 "Bigtable delete race exhausted",
1460 ))
1461 }
1462
1463 #[tracing::instrument(level = "debug", skip(self, write))]
1464 async fn compare_and_write(
1465 &self,
1466 id: &ObjectId,
1467 current: Option<&ObjectId>,
1468 write: TieredWrite,
1469 access_time: Timestamp,
1470 ) -> Result<bool> {
1471 objectstore_log::debug!("CAS put to Bigtable backend");
1472
1473 let path = id.as_storage_path().to_string().into_bytes();
1474 let predicate = match (current, write.target()) {
1475 (Some(old), Some(new)) => update_predicate(old, new, id, access_time),
1476 (Some(target), None) => optional_target_predicate(target, id, access_time),
1477 (None, Some(target)) => optional_target_predicate(target, id, access_time),
1478 (None, None) => tombstone_predicate(access_time),
1479 };
1480
1481 let (mutations, expires_at): (Vec<v2::Mutation>, Option<Option<Timestamp>>) = match write {
1485 TieredWrite::Tombstone(tombstone) => (
1486 tombstone_mutations(&tombstone).into(),
1487 Some(tombstone.time_expires),
1488 ),
1489 TieredWrite::Object(m, p) => {
1490 let expires_at = m.time_expires;
1491 let (mutations, _) = object_mutations(&path, m, p.to_vec())?;
1492 (mutations.into(), Some(expires_at))
1493 }
1494 TieredWrite::Delete => (vec![delete_row_mutation()], None),
1495 };
1496 if let Some(expires_at) = expires_at {
1497 self.change_stream.begin_write(id, expires_at).await?;
1498 }
1499
1500 let written = self
1501 .check_and_mutate(
1502 path.clone(),
1503 predicate,
1504 mutations.clone(),
1505 "compare_and_write",
1506 )
1507 .await?;
1508
1509 match (written, expires_at) {
1510 (false, _) => {}
1512 (true, Some(expires_at)) => {
1514 self.change_stream
1515 .commit_write(id, row_size(&path, &mutations), expires_at)
1516 .await
1517 }
1518 (true, None) => self.change_stream.commit_delete(id).await,
1520 }
1521
1522 Ok(written)
1523 }
1524}
1525
1526async fn retry<T, F>(context: &'static str, f: impl Fn() -> F) -> Result<T>
1528where
1529 F: Future<Output = Result<T, BigTableError>> + Send,
1530{
1531 let mut retry_count = 0usize;
1532
1533 loop {
1534 let attempt_span = tracing::debug_span!(
1535 "bigtable.request",
1536 action = context,
1537 grpc.status = tracing::field::Empty,
1538 );
1539 let attempt = async {
1540 let result = f().await;
1541 let span = tracing::Span::current();
1542 match &result {
1543 Ok(_) => span.record("grpc.status", "ok"),
1544 Err(BigTableError::RpcError(status)) => {
1545 span.record("grpc.status", tracing::field::debug(status.code()))
1546 }
1547 Err(_) => &span,
1549 };
1550 result
1551 };
1552
1553 match attempt.instrument(attempt_span).await {
1554 Ok(res) => return Ok(res),
1555 Err(e) if retry_count >= REQUEST_RETRY_COUNT || !is_retryable(&e) => {
1556 objectstore_metrics::count!("bigtable.failures", action = context);
1557 return Err(e).context(
1558 ErrorKind::BackendFailure,
1559 format!("running Bigtable {context}"),
1560 );
1561 }
1562 Err(e) => {
1563 retry_count += 1;
1564 objectstore_metrics::count!("bigtable.retries", action = context);
1565 objectstore_log::warn!(!!&e, retry_count, context, "Retrying request");
1566 }
1567 }
1568 }
1569}
1570
1571fn is_retryable(error: &BigTableError) -> bool {
1572 match error {
1573 BigTableError::GCPAuthError(_) => true,
1575 BigTableError::TransportError(_) => true,
1577 BigTableError::IoError(_) => true,
1579 BigTableError::TimeoutError(_) => true,
1580
1581 BigTableError::RpcError(status) => match status.code() {
1583 Code::Unavailable => true,
1585 Code::Cancelled => true,
1587 Code::DeadlineExceeded => true,
1588 Code::Unauthenticated => true,
1590 Code::Aborted => true,
1592 Code::Internal => true,
1593 Code::FailedPrecondition => true,
1594 Code::Unknown => true,
1595 _ => false,
1596 },
1597 _ => false,
1598 }
1599}
1600
1601fn apply_range(payload: Bytes, range: Option<ByteRange>) -> Result<(Option<ContentRange>, Bytes)> {
1607 let Some(byte_range) = range else {
1608 return Ok((None, payload));
1609 };
1610
1611 let total = payload.len() as u64;
1612 let content_range = byte_range
1613 .resolve(total)
1614 .ok_or(ErrorKind::RangeNotSatisfiable { total })?;
1615
1616 let sliced = payload.slice(content_range.start as usize..content_range.end as usize + 1);
1617 Ok((Some(content_range), sliced))
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622 use std::collections::BTreeMap;
1623
1624 use anyhow::Result;
1625 #[cfg(feature = "storage-cogs")]
1626 use objectstore_inventory_tracker::{OpType, test_utils::DummyProducer};
1627 use objectstore_types::metadata::ExpirationPolicy;
1628 use objectstore_types::scope::{Scope, Scopes};
1629
1630 use super::*;
1631 use crate::backend::common::ExpiryTarget;
1632 use crate::id::ObjectContext;
1633 use crate::stream;
1634
1635 fn test_config() -> BigTableConfig {
1641 BigTableConfig {
1642 endpoint: Some("localhost:8086".into()),
1643 project_id: "testing".into(),
1644 instance_name: "objectstore".into(),
1645 table_name: "objectstore".into(),
1646 connections: None,
1647 rpc_timeout: default_rpc_timeout(),
1648 cogs: None,
1649 }
1650 }
1651
1652 async fn create_test_backend() -> Result<BigTableBackend> {
1653 BigTableBackend::new(test_config(), &ChangeStreamFactory::default()).await
1654 }
1655
1656 #[cfg(feature = "storage-cogs")]
1657 async fn create_test_backend_with_change_stream() -> Result<(BigTableBackend, DummyProducer)> {
1658 let (streams, producer) = crate::change_stream::dummy_factory();
1659 let config = BigTableConfig {
1660 cogs: Some(CostTrackerStreamConfig {
1661 shared_resource_id: "bigtable_objectstore".into(),
1662 sample_rate: 1.0,
1663 }),
1664 ..test_config()
1665 };
1666
1667 Ok((BigTableBackend::new(config, &streams).await?, producer))
1668 }
1669
1670 fn make_id() -> ObjectId {
1671 ObjectId::random(ObjectContext {
1672 usecase: "testing".parse().unwrap(),
1673 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1674 })
1675 }
1676
1677 async fn create_object(
1678 backend: &BigTableBackend,
1679 id: &ObjectId,
1680 metadata: &Metadata,
1681 payload: &[u8],
1682 now: Timestamp,
1683 ) -> Result<()> {
1684 let path = id.as_storage_path().to_string().into_bytes();
1685 let mut metadata = metadata.clone();
1688 if metadata.time_expires.is_none() {
1689 metadata.time_expires = metadata.expiration_policy.expires_in().map(|ttl| now + ttl);
1690 }
1691 let (mutations, _) = object_mutations(&path, metadata, payload.to_vec())?;
1692 backend.mutate(path, mutations, "test-setup").await?;
1693 Ok(())
1694 }
1695
1696 async fn create_tombstone(
1697 backend: &BigTableBackend,
1698 id: &ObjectId,
1699 tombstone: &Tombstone,
1700 ) -> Result<()> {
1701 let path = id.as_storage_path().to_string().into_bytes();
1702 let mutations = tombstone_mutations(tombstone);
1703 backend.mutate(path, mutations, "test-setup").await?;
1704 Ok(())
1705 }
1706
1707 async fn write_legacy_tombstone(
1709 backend: &BigTableBackend,
1710 id: &ObjectId,
1711 expiration_policy: ExpirationPolicy,
1712 time_expires: Option<Timestamp>,
1713 ) -> Result<()> {
1714 let meta = if expiration_policy.is_manual() {
1715 r#"{"is_redirect_tombstone":true}"#.to_owned()
1716 } else {
1717 let policy_json = serde_json::to_string(&expiration_policy).unwrap();
1718 format!(r#"{{"is_redirect_tombstone":true,"expiration_policy":{policy_json}}}"#)
1719 };
1720
1721 let (family, timestamp_micros) = if expiration_policy.is_manual() {
1722 (FAMILY_MANUAL, -1)
1723 } else {
1724 let t =
1725 time_expires.unwrap_or(Timestamp::now() + expiration_policy.expires_in().unwrap());
1726 (FAMILY_GC, t.as_micros() as i64)
1727 };
1728
1729 let path = id.as_storage_path().to_string().into_bytes();
1730 let mutations = [mutation(mutation::Mutation::SetCell(mutation::SetCell {
1731 family_name: family.to_owned(),
1732 column_qualifier: COLUMN_METADATA.to_owned(),
1733 timestamp_micros,
1734 value: meta.into_bytes(),
1735 }))];
1736
1737 backend.mutate(path, mutations, "test-setup").await?;
1738
1739 Ok(())
1740 }
1741
1742 async fn write_empty_redirect_tombstone(
1744 backend: &BigTableBackend,
1745 id: &ObjectId,
1746 ) -> Result<()> {
1747 let path = id.as_storage_path().to_string().into_bytes();
1748 let mutations = [
1749 mutation(mutation::Mutation::SetCell(mutation::SetCell {
1750 family_name: FAMILY_MANUAL.to_owned(),
1751 column_qualifier: COLUMN_REDIRECT.to_owned(),
1752 timestamp_micros: -1,
1753 value: b"".to_vec(), })),
1755 mutation(mutation::Mutation::SetCell(mutation::SetCell {
1756 family_name: FAMILY_MANUAL.to_owned(),
1757 column_qualifier: b"t".to_vec(),
1758 timestamp_micros: -1,
1759 value: b"{}".to_vec(),
1760 })),
1761 ];
1762
1763 backend.mutate(path, mutations, "test-setup").await?;
1764
1765 Ok(())
1766 }
1767
1768 #[tokio::test]
1772 async fn test_roundtrip() -> Result<()> {
1773 let backend = create_test_backend().await?;
1774
1775 let id = make_id();
1776 let metadata = Metadata {
1777 content_type: "text/plain".into(),
1778 time_created: Some(Timestamp::now()),
1779 custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1780 ..Default::default()
1781 };
1782
1783 backend
1784 .put_object(
1785 &id,
1786 &metadata,
1787 stream::single("hello, world"),
1788 Timestamp::now(),
1789 )
1790 .await?;
1791
1792 let (obj_meta, _, stream) = backend
1793 .get_object(&id, Timestamp::now(), None)
1794 .await?
1795 .unwrap();
1796 let payload = stream::read_to_vec(stream).await?;
1797 assert_eq!(payload, b"hello, world");
1798 assert_eq!(obj_meta.content_type, metadata.content_type);
1799 assert_eq!(obj_meta.custom, metadata.custom);
1800
1801 let head_meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
1802 assert_eq!(head_meta.content_type, metadata.content_type);
1803 assert_eq!(head_meta.custom, metadata.custom);
1804
1805 Ok(())
1806 }
1807
1808 #[tokio::test]
1810 async fn test_time_expires_roundtrip() -> Result<()> {
1811 let backend = create_test_backend().await?;
1812
1813 let id = make_id();
1814 let ttl = Duration::from_hours(2 * 24);
1815 let expires = Timestamp::now() + ttl;
1816 let metadata = Metadata {
1817 expiration_policy: ExpirationPolicy::TimeToLive(ttl),
1818 time_expires: Some(expires),
1819 ..Default::default()
1820 };
1821 create_object(&backend, &id, &metadata, b"data", Timestamp::now()).await?;
1822
1823 let meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
1824 assert_eq!(meta.time_expires, Some(expires));
1825
1826 Ok(())
1827 }
1828
1829 #[tokio::test]
1831 async fn test_nonexistent() -> Result<()> {
1832 let backend = create_test_backend().await?;
1833
1834 let id = make_id();
1835 assert!(
1836 backend
1837 .get_object(&id, Timestamp::now(), None)
1838 .await?
1839 .is_none()
1840 );
1841 assert!(backend.get_metadata(&id, Timestamp::now()).await?.is_none());
1842 backend.delete_object(&id, Timestamp::now()).await?;
1843
1844 Ok(())
1845 }
1846
1847 #[tokio::test]
1848 async fn test_overwrite() -> Result<()> {
1849 let backend = create_test_backend().await?;
1850
1851 let id = make_id();
1852 let first_metadata = Metadata {
1853 custom: BTreeMap::from_iter([("invalid".into(), "invalid".into())]),
1854 ..Default::default()
1855 };
1856 create_object(&backend, &id, &first_metadata, b"hello", Timestamp::now()).await?;
1857
1858 let second_metadata = Metadata {
1859 custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1860 ..Default::default()
1861 };
1862 backend
1863 .put_object(
1864 &id,
1865 &second_metadata,
1866 stream::single("world"),
1867 Timestamp::now(),
1868 )
1869 .await?;
1870
1871 let (meta, _, stream) = backend
1872 .get_object(&id, Timestamp::now(), None)
1873 .await?
1874 .unwrap();
1875 let payload = stream::read_to_vec(stream).await?;
1876 assert_eq!(payload, b"world");
1877 assert_eq!(meta.custom, second_metadata.custom);
1878
1879 Ok(())
1880 }
1881
1882 #[tokio::test]
1883 async fn test_read_after_delete() -> Result<()> {
1884 let backend = create_test_backend().await?;
1885
1886 let id = make_id();
1887 let metadata = Metadata::default();
1888 create_object(&backend, &id, &metadata, b"hello", Timestamp::now()).await?;
1889 backend.delete_object(&id, Timestamp::now()).await?;
1890
1891 assert!(
1892 backend
1893 .get_object(&id, Timestamp::now(), None)
1894 .await?
1895 .is_none()
1896 );
1897
1898 Ok(())
1899 }
1900
1901 #[tokio::test]
1903 async fn test_set_expiry() -> Result<()> {
1904 for is_ttl in [false, true] {
1905 let backend = create_test_backend().await?;
1906 let tti = Duration::from_hours(2 * 24);
1907 let mut metadata = Metadata {
1908 expiration_policy: if is_ttl {
1909 ExpirationPolicy::TimeToLive(tti)
1910 } else {
1911 ExpirationPolicy::TimeToIdle(tti)
1912 },
1913 ..Default::default()
1914 };
1915
1916 let past_now = Timestamp::now() - tti + Duration::from_mins(1);
1918
1919 let id = make_id();
1920 metadata.time_created = Some(past_now);
1921 metadata.time_expires = Some(past_now + tti);
1922 let path = id.as_storage_path().to_string().into_bytes();
1923 let (mutations, _) = object_mutations(&path, metadata, b"hello, world".to_vec())?;
1924 let mutations = mutations.map(|mut mutation| {
1926 if let Some(mutation::Mutation::SetCell(cell)) = &mut mutation.mutation {
1927 cell.timestamp_micros -= 500_000;
1928 }
1929 mutation
1930 });
1931 backend.mutate(path, mutations, "test-setup").await?;
1932
1933 let (observed, _, _) = backend
1934 .get_object(&id, Timestamp::now(), None)
1935 .await?
1936 .unwrap();
1937 let observed_expiry = observed.time_expires.unwrap();
1938 assert_eq!(
1939 backend
1940 .get_metadata(&id, Timestamp::now())
1941 .await?
1942 .unwrap()
1943 .time_expires,
1944 Some(observed_expiry),
1945 "backend reads must not renew TTI"
1946 );
1947
1948 let requested = past_now + tti + tti;
1949 for target in [
1950 ExpiryTarget::At(requested),
1951 ExpiryTarget::FromCreation(tti + tti),
1952 ] {
1953 assert_eq!(
1954 backend
1955 .set_expiry(&id, target.into(), Timestamp::now())
1956 .await?,
1957 SetExpiryResponse::Satisfied(requested)
1958 );
1959 }
1960 assert_eq!(
1961 backend
1962 .get_metadata(&id, Timestamp::now())
1963 .await?
1964 .unwrap()
1965 .time_expires,
1966 Some(requested)
1967 );
1968 let (updated, _, stream) = backend
1969 .get_object(&id, Timestamp::now(), None)
1970 .await?
1971 .unwrap();
1972 assert_eq!(
1973 updated.expiration_policy,
1974 if is_ttl {
1975 ExpirationPolicy::TimeToLive(tti + tti)
1976 } else {
1977 ExpirationPolicy::TimeToIdle(tti)
1978 }
1979 );
1980 let payload = stream::read_to_vec(stream).await?;
1981 assert_eq!(payload, b"hello, world");
1982 }
1983 Ok(())
1984 }
1985
1986 #[tokio::test]
1987 async fn test_expiry_outcomes() -> Result<()> {
1988 let backend = create_test_backend().await?;
1989 let access_time = Timestamp::now();
1990 let deadline = access_time + Duration::from_hours(1);
1991 for (expiry, expected) in [
1992 (None, SetExpiryResponse::Rejected),
1993 (
1994 Some(access_time - Duration::from_secs(1)),
1995 SetExpiryResponse::NotFound,
1996 ),
1997 (Some(deadline), SetExpiryResponse::Rejected),
1998 ] {
1999 let id = make_id();
2000 let metadata = Metadata {
2001 time_expires: expiry,
2002 ..Default::default()
2003 };
2004 create_object(&backend, &id, &metadata, b"payload", access_time).await?;
2005 assert_eq!(
2006 backend
2007 .set_expiry(
2008 &id,
2009 ExpiryTarget::FromCreation(Duration::from_hours(2)).into(),
2010 access_time
2011 )
2012 .await?,
2013 expected
2014 );
2015 }
2016 Ok(())
2017 }
2018
2019 #[tokio::test]
2020 async fn test_expiry_conflict() -> Result<()> {
2021 let backend = create_test_backend().await?;
2022 let missing = make_id();
2023 assert_eq!(
2024 backend
2025 .set_expiry(
2026 &missing,
2027 ExpiryTarget::At(Timestamp::now() + Duration::from_hours(2)).into(),
2028 Timestamp::now()
2029 )
2030 .await?,
2031 SetExpiryResponse::NotFound
2032 );
2033
2034 let id = make_id();
2035 let observed_expiry = Timestamp::now() + Duration::from_hours(1);
2036 let original = Metadata {
2037 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
2038 time_expires: Some(observed_expiry),
2039 ..Default::default()
2040 };
2041 create_object(&backend, &id, &original, b"original", Timestamp::now()).await?;
2042
2043 let path = id.as_storage_path().to_string().into_bytes();
2044 let mut extended = original.clone();
2045 extended.time_expires = Some(observed_expiry + Duration::from_hours(1));
2046 let (extension, _) = object_mutations(&path, extended, b"original".to_vec())?;
2047 let predicate =
2048 inline_expiry_predicate(observed_expiry.as_micros() as i64, Timestamp::now())?;
2049
2050 let mut replacement = original.clone();
2051 replacement.time_expires = Some(observed_expiry + Duration::from_mins(1));
2052 create_object(
2053 &backend,
2054 &id,
2055 &replacement,
2056 b"replacement",
2057 Timestamp::now(),
2058 )
2059 .await?;
2060 assert!(
2061 !backend
2062 .check_and_mutate(path, predicate, extension, "test-expiry-conflict")
2063 .await?
2064 );
2065 let (_, _, payload) = backend
2066 .get_object(&id, Timestamp::now(), None)
2067 .await?
2068 .unwrap();
2069 assert_eq!(stream::read_to_vec(payload).await?, b"replacement");
2070 Ok(())
2071 }
2072
2073 #[tokio::test]
2074 async fn test_redirect_expiry() -> Result<()> {
2075 let backend = create_test_backend().await?;
2076 let id = make_id();
2077 let target = ObjectId::random(id.context().clone());
2078 let wrong_target = ObjectId::random(id.context().clone());
2079 let old_expiry = Timestamp::now() + Duration::from_hours(1);
2080 let path = id.as_storage_path().to_string().into_bytes();
2081 let mutations = tombstone_mutations(&Tombstone {
2082 target: target.clone(),
2083 time_expires: Some(old_expiry),
2084 })
2085 .map(|mut mutation| {
2086 if let Some(mutation::Mutation::SetCell(cell)) = &mut mutation.mutation {
2087 cell.timestamp_micros -= 500_000;
2088 }
2089 mutation
2090 });
2091 backend.mutate(path, mutations, "test-setup").await?;
2092
2093 let later = old_expiry + Duration::from_hours(2);
2094 assert_eq!(
2095 backend
2096 .compare_and_update(
2097 &id,
2098 Some(&wrong_target),
2099 TieredUpdate::SetExpiry(ExpiryTarget::At(later).into()),
2100 Timestamp::now(),
2101 )
2102 .await?,
2103 SetExpiryResponse::Rejected
2104 );
2105 assert_eq!(
2106 backend
2107 .compare_and_update(
2108 &id,
2109 Some(&target),
2110 TieredUpdate::SetExpiry(ExpiryTarget::At(later).into()),
2111 Timestamp::now()
2112 )
2113 .await?,
2114 SetExpiryResponse::Satisfied(later)
2115 );
2116 let requested = old_expiry + Duration::from_mins(30);
2117 assert_eq!(
2118 backend
2119 .compare_and_update(
2120 &id,
2121 Some(&target),
2122 TieredUpdate::SetExpiry(ExpiryTarget::At(requested).into()),
2123 Timestamp::now(),
2124 )
2125 .await?,
2126 SetExpiryResponse::Satisfied(requested)
2127 );
2128 let TieredMetadata::Tombstone(tombstone) =
2129 backend.get_tiered_metadata(&id, Timestamp::now()).await?
2130 else {
2131 panic!("expected tombstone");
2132 };
2133 assert_eq!(tombstone.time_expires, Some(later));
2134 Ok(())
2135 }
2136
2137 #[tokio::test]
2140 async fn test_ttl_immediate() -> Result<()> {
2141 let backend = create_test_backend().await?;
2145
2146 let id = make_id();
2147 let metadata = Metadata {
2148 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
2149 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2150 ..Default::default()
2151 };
2152 create_object(&backend, &id, &metadata, b"hello, world", Timestamp::now()).await?;
2153
2154 assert!(
2155 backend
2156 .get_object(&id, Timestamp::now(), None)
2157 .await?
2158 .is_none()
2159 );
2160
2161 Ok(())
2162 }
2163
2164 #[tokio::test]
2165 async fn test_tti_immediate() -> Result<()> {
2166 let backend = create_test_backend().await?;
2170
2171 let id = make_id();
2172 let metadata = Metadata {
2173 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
2174 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2175 ..Default::default()
2176 };
2177 create_object(&backend, &id, &metadata, b"hello, world", Timestamp::now()).await?;
2178
2179 assert!(
2180 backend
2181 .get_object(&id, Timestamp::now(), None)
2182 .await?
2183 .is_none()
2184 );
2185
2186 Ok(())
2187 }
2188
2189 #[tokio::test]
2198 async fn test_tiered_get() -> Result<()> {
2199 let backend = create_test_backend().await?;
2200
2201 let id = make_id();
2203 assert!(matches!(
2204 backend
2205 .get_tiered_object(&id, Timestamp::now(), None)
2206 .await?,
2207 TieredGet::NotFound
2208 ));
2209 assert!(matches!(
2210 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2211 TieredMetadata::NotFound
2212 ));
2213
2214 let id = make_id();
2216 let put_meta = Metadata {
2217 content_type: "text/plain".into(),
2218 custom: BTreeMap::from_iter([("k".into(), "v".into())]),
2219 ..Default::default()
2220 };
2221 create_object(&backend, &id, &put_meta, b"payload", Timestamp::now()).await?;
2222
2223 let TieredGet::Object(obj_meta, _, obj_stream) = backend
2224 .get_tiered_object(&id, Timestamp::now(), None)
2225 .await?
2226 else {
2227 panic!("expected TieredGet::Object");
2228 };
2229 let obj_payload = stream::read_to_vec(obj_stream).await?;
2230 assert_eq!(obj_payload, b"payload");
2231 assert_eq!(obj_meta.content_type, put_meta.content_type);
2232 assert_eq!(obj_meta.custom, put_meta.custom);
2233
2234 let TieredMetadata::Object(head_meta) =
2235 backend.get_tiered_metadata(&id, Timestamp::now()).await?
2236 else {
2237 panic!("expected TieredMetadata::Object");
2238 };
2239 assert_eq!(head_meta.content_type, put_meta.content_type);
2240 assert_eq!(head_meta.custom, put_meta.custom);
2241
2242 let hv_id = make_id();
2244 let lt_id = ObjectId::random(hv_id.context().clone());
2245 let tombstone = Tombstone {
2246 target: lt_id.clone(),
2247 time_expires: None,
2248 };
2249 create_tombstone(&backend, &hv_id, &tombstone).await?;
2250
2251 match backend
2252 .get_tiered_object(&hv_id, Timestamp::now(), None)
2253 .await?
2254 {
2255 TieredGet::Tombstone(get_t) => assert_eq!(get_t.target, lt_id),
2256 other => panic!("expected TieredGet::Tombstone, got {other:?}"),
2257 }
2258 match backend
2259 .get_tiered_metadata(&hv_id, Timestamp::now())
2260 .await?
2261 {
2262 TieredMetadata::Tombstone(meta_t) => assert_eq!(meta_t.target, lt_id,),
2263 other => panic!("expected TieredMetadata::Tombstone, got {other:?}"),
2264 }
2265
2266 Ok(())
2267 }
2268
2269 #[tokio::test]
2275 async fn test_put_non_tombstone() -> Result<()> {
2276 let backend = create_test_backend().await?;
2277
2278 let id = make_id();
2280 let metadata = Metadata::default();
2281 let result = backend
2282 .put_non_tombstone(
2283 &id,
2284 &metadata,
2285 Bytes::from_static(b"first"),
2286 Timestamp::now(),
2287 )
2288 .await?;
2289 assert_eq!(result, None, "expected None on empty row");
2290 let (_, _, stream) = backend
2291 .get_object(&id, Timestamp::now(), None)
2292 .await?
2293 .unwrap();
2294 assert_eq!(&stream::read_to_vec(stream).await?, b"first");
2295
2296 let id = make_id();
2298 create_object(&backend, &id, &metadata, b"old", Timestamp::now()).await?;
2299 let result = backend
2300 .put_non_tombstone(&id, &metadata, Bytes::from_static(b"new"), Timestamp::now())
2301 .await?;
2302 assert_eq!(result, None, "expected None when overwriting object");
2303 let (_, _, stream) = backend
2304 .get_object(&id, Timestamp::now(), None)
2305 .await?
2306 .unwrap();
2307 assert_eq!(&stream::read_to_vec(stream).await?, b"new");
2308
2309 let hv_id = make_id();
2311 let lt_id = ObjectId::random(hv_id.context().clone());
2312 let tombstone = Tombstone {
2313 target: lt_id.clone(),
2314 time_expires: None,
2315 };
2316 create_tombstone(&backend, &hv_id, &tombstone).await?;
2317 let result = backend
2318 .put_non_tombstone(&hv_id, &metadata, Bytes::new(), Timestamp::now())
2319 .await?;
2320 let returned = result.expect("expected Some(Tombstone) when row is a tombstone");
2321 assert_eq!(returned.target, lt_id);
2322 assert!(
2323 matches!(
2324 backend
2325 .get_tiered_metadata(&hv_id, Timestamp::now())
2326 .await?,
2327 TieredMetadata::Tombstone(_)
2328 ),
2329 "tombstone must still exist after put_non_tombstone"
2330 );
2331
2332 Ok(())
2333 }
2334
2335 #[tokio::test]
2344 async fn test_delete_non_tombstone() -> Result<()> {
2345 let backend = create_test_backend().await?;
2346
2347 let id = make_id();
2349 assert_eq!(
2350 backend.delete_non_tombstone(&id, Timestamp::now()).await?,
2351 None
2352 );
2353
2354 let id = make_id();
2356 let metadata = Metadata::default();
2357 create_object(&backend, &id, &metadata, b"hello, world", Timestamp::now()).await?;
2358 assert_eq!(
2359 backend.delete_non_tombstone(&id, Timestamp::now()).await?,
2360 None
2361 );
2362 assert!(
2363 backend
2364 .get_object(&id, Timestamp::now(), None)
2365 .await?
2366 .is_none()
2367 );
2368
2369 let id = make_id();
2371 let tombstone = Tombstone {
2372 target: id.clone(),
2373 time_expires: None,
2374 };
2375 create_tombstone(&backend, &id, &tombstone).await?;
2376 let tombstone = backend
2377 .delete_non_tombstone(&id, Timestamp::now())
2378 .await?
2379 .expect("expected Some(tombstone)");
2380 assert_eq!(tombstone.target, id, "tombstone target must be returned");
2381 assert!(
2382 matches!(
2383 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2384 TieredMetadata::Tombstone(_)
2385 ),
2386 "tombstone must still exist after delete_non_tombstone"
2387 );
2388
2389 Ok(())
2390 }
2391
2392 #[tokio::test]
2398 async fn test_cas_create_tombstone() -> Result<()> {
2399 let backend = create_test_backend().await?;
2400
2401 let hv_id = make_id();
2402 let lt_id = ObjectId::random(hv_id.context().clone());
2403 let time_expires = Some(Timestamp::now() + Duration::from_hours(1));
2404 let tombstone = Tombstone {
2405 target: lt_id.clone(),
2406 time_expires,
2407 };
2408
2409 let committed = backend
2411 .compare_and_write(
2412 &hv_id,
2413 None,
2414 TieredWrite::Tombstone(tombstone.clone()),
2415 Timestamp::now(),
2416 )
2417 .await?;
2418 assert!(committed, "expected CAS success on empty row");
2419
2420 let TieredMetadata::Tombstone(t) = backend
2422 .get_tiered_metadata(&hv_id, Timestamp::now())
2423 .await?
2424 else {
2425 panic!("expected TieredMetadata::Tombstone");
2426 };
2427 assert_eq!(t.target, lt_id, "target must round-trip via r column");
2428 assert_eq!(t.time_expires, time_expires);
2429 match backend
2430 .get_tiered_object(&hv_id, Timestamp::now(), None)
2431 .await?
2432 {
2433 TieredGet::Tombstone(t) => assert_eq!(t.target, lt_id, "round-trip via r column"),
2434 other => panic!("expected TieredGet::Tombstone, got {other:?}"),
2435 }
2436
2437 assert!(
2439 backend
2440 .get_object(&hv_id, Timestamp::now(), None)
2441 .await
2442 .is_err_and(|error| error.kind() == ErrorKind::UnexpectedTombstone)
2443 );
2444 assert!(
2445 backend
2446 .get_metadata(&hv_id, Timestamp::now())
2447 .await
2448 .is_err_and(|error| error.kind() == ErrorKind::UnexpectedTombstone)
2449 );
2450
2451 let second = backend
2453 .compare_and_write(
2454 &hv_id,
2455 None,
2456 TieredWrite::Tombstone(tombstone),
2457 Timestamp::now(),
2458 )
2459 .await?;
2460 assert!(second, "idempotent retry");
2461
2462 Ok(())
2463 }
2464
2465 #[tokio::test]
2467 async fn test_cas_swap_tombstone() -> Result<()> {
2468 let backend = create_test_backend().await?;
2469
2470 let hv_id = make_id();
2471 let old_lt_id = ObjectId::random(hv_id.context().clone());
2472 let wrong_lt_id = ObjectId::random(hv_id.context().clone());
2473 let new_lt_id = ObjectId::random(hv_id.context().clone());
2474
2475 let tombstone = Tombstone {
2476 target: old_lt_id.clone(),
2477 time_expires: None,
2478 };
2479 create_tombstone(&backend, &hv_id, &tombstone).await?;
2480
2481 let write = TieredWrite::Tombstone(Tombstone {
2483 target: new_lt_id.clone(),
2484 time_expires: None,
2485 });
2486 let swapped = backend
2487 .compare_and_write(&hv_id, Some(&wrong_lt_id), write.clone(), Timestamp::now())
2488 .await?;
2489 assert!(!swapped, "expected CAS failure due to wrong target");
2490 match backend
2491 .get_tiered_metadata(&hv_id, Timestamp::now())
2492 .await?
2493 {
2494 TieredMetadata::Tombstone(t) => assert_eq!(t.target, old_lt_id),
2495 other => panic!("expected tombstone, got {other:?}"),
2496 }
2497
2498 let swapped = backend
2500 .compare_and_write(&hv_id, Some(&old_lt_id), write.clone(), Timestamp::now())
2501 .await?;
2502 assert!(swapped, "expected CAS success with correct target");
2503 match backend
2504 .get_tiered_metadata(&hv_id, Timestamp::now())
2505 .await?
2506 {
2507 TieredMetadata::Tombstone(t) => assert_eq!(t.target, new_lt_id),
2508 other => panic!("expected tombstone, got {other:?}"),
2509 }
2510
2511 let retry = backend
2513 .compare_and_write(&hv_id, Some(&old_lt_id), write, Timestamp::now())
2514 .await?;
2515 assert!(retry, "idempotent retry");
2516
2517 Ok(())
2518 }
2519
2520 #[tokio::test]
2522 async fn test_cas_swap_inline() -> Result<()> {
2523 let backend = create_test_backend().await?;
2524
2525 let id = make_id();
2526 let lt_id = ObjectId::random(id.context().clone());
2527 let wrong_id = ObjectId::random(id.context().clone());
2528
2529 let tombstone = Tombstone {
2530 target: lt_id.clone(),
2531 time_expires: None,
2532 };
2533 create_tombstone(&backend, &id, &tombstone).await?;
2534
2535 let write = TieredWrite::Object(Metadata::default(), Bytes::new());
2537 let swapped = backend
2538 .compare_and_write(&id, Some(&wrong_id), write, Timestamp::now())
2539 .await?;
2540 assert!(!swapped, "expected CAS failure with wrong target");
2541 assert!(matches!(
2542 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2543 TieredMetadata::Tombstone(_)
2544 ));
2545
2546 let payload = Bytes::from_static(b"hello inline");
2548 let write = TieredWrite::Object(Metadata::default(), payload.clone());
2549 let swapped = backend
2550 .compare_and_write(&id, Some(<_id), write.clone(), Timestamp::now())
2551 .await?;
2552 assert!(swapped, "expected CAS success with correct target");
2553 let TieredGet::Object(_, _, stream) = backend
2554 .get_tiered_object(&id, Timestamp::now(), None)
2555 .await?
2556 else {
2557 panic!("expected inline object after swap");
2558 };
2559 assert_eq!(&stream::read_to_vec(stream).await?, payload.as_ref());
2560
2561 let retry = backend
2563 .compare_and_write(&id, Some(<_id), write, Timestamp::now())
2564 .await?;
2565 assert!(retry, "idempotent retry");
2566
2567 Ok(())
2568 }
2569
2570 #[tokio::test]
2572 async fn test_cas_create_object_on_empty_row() -> Result<()> {
2573 let backend = create_test_backend().await?;
2574
2575 let id = make_id();
2576 let payload = Bytes::from_static(b"cas object");
2577 let write = TieredWrite::Object(Metadata::default(), payload.clone());
2578 let committed = backend
2579 .compare_and_write(&id, None, write, Timestamp::now())
2580 .await?;
2581 assert!(committed, "expected CAS success on empty row");
2582
2583 let TieredGet::Object(_, _, stream) = backend
2584 .get_tiered_object(&id, Timestamp::now(), None)
2585 .await?
2586 else {
2587 panic!("expected Object after CAS-create");
2588 };
2589 assert_eq!(&stream::read_to_vec(stream).await?, payload.as_ref());
2590
2591 Ok(())
2592 }
2593
2594 #[tokio::test]
2596 async fn test_cas_delete() -> Result<()> {
2597 let backend = create_test_backend().await?;
2598
2599 let id = make_id();
2600 let lt_id = ObjectId::random(id.context().clone());
2601 let wrong_id = ObjectId::random(id.context().clone());
2602
2603 let tombstone = Tombstone {
2604 target: lt_id.clone(),
2605 time_expires: None,
2606 };
2607 create_tombstone(&backend, &id, &tombstone).await?;
2608
2609 let deleted = backend
2611 .compare_and_write(&id, Some(&wrong_id), TieredWrite::Delete, Timestamp::now())
2612 .await?;
2613 assert!(!deleted, "expected CAS failure with wrong target");
2614 assert!(matches!(
2615 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2616 TieredMetadata::Tombstone(_)
2617 ));
2618
2619 let deleted = backend
2621 .compare_and_write(&id, Some(<_id), TieredWrite::Delete, Timestamp::now())
2622 .await?;
2623 assert!(deleted, "expected CAS delete success");
2624 assert!(matches!(
2625 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2626 TieredMetadata::NotFound
2627 ));
2628
2629 let retry = backend
2631 .compare_and_write(&id, Some(<_id), TieredWrite::Delete, Timestamp::now())
2632 .await?;
2633 assert!(retry, "idempotent retry");
2634
2635 let id2 = make_id();
2637 let fake_lt_id = ObjectId::random(id2.context().clone());
2638 let metadata = Metadata::default();
2639 create_object(&backend, &id2, &metadata, b"data", Timestamp::now()).await?;
2640 let deleted = backend
2641 .compare_and_write(
2642 &id2,
2643 Some(&fake_lt_id),
2644 TieredWrite::Delete,
2645 Timestamp::now(),
2646 )
2647 .await?;
2648 assert!(deleted, "expected idempotent deletion");
2649
2650 Ok(())
2651 }
2652
2653 #[tokio::test]
2660 async fn test_legacy_tombstone_reads() -> Result<()> {
2661 let backend = create_test_backend().await?;
2662
2663 let id = make_id();
2665 write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2666
2667 let TieredMetadata::Tombstone(t) =
2668 backend.get_tiered_metadata(&id, Timestamp::now()).await?
2669 else {
2670 panic!("expected tombstone");
2671 };
2672 assert_eq!(t.time_expires, None);
2673 assert!(matches!(
2674 backend
2675 .get_tiered_object(&id, Timestamp::now(), None)
2676 .await?,
2677 TieredGet::Tombstone(_)
2678 ));
2679
2680 let id = make_id();
2685 let ttl = Duration::from_hours(2 * 24);
2686 write_legacy_tombstone(&backend, &id, ExpirationPolicy::TimeToLive(ttl), None).await?;
2687
2688 let TieredMetadata::Tombstone(t) =
2689 backend.get_tiered_metadata(&id, Timestamp::now()).await?
2690 else {
2691 panic!("expected TieredMetadata::Tombstone");
2692 };
2693 assert!(t.time_expires.is_some());
2694
2695 Ok(())
2696 }
2697
2698 #[tokio::test]
2700 async fn test_legacy_tombstone_tti_upgrade() -> Result<()> {
2701 let backend = create_test_backend().await?;
2702 let id = make_id();
2703 let path = id.as_storage_path().to_string().into_bytes();
2704
2705 let tti = Duration::from_hours(2 * 24);
2706
2707 let old_deadline = Timestamp::now() + Duration::from_mins(1);
2709 write_legacy_tombstone(
2710 &backend,
2711 &id,
2712 ExpirationPolicy::TimeToIdle(tti),
2713 Some(old_deadline),
2714 )
2715 .await?;
2716
2717 let TieredMetadata::Tombstone(_) =
2719 backend.get_tiered_metadata(&id, Timestamp::now()).await?
2720 else {
2721 panic!("expected tombstone");
2722 };
2723 assert_eq!(
2724 backend
2725 .read_row(&path, "test-verify", Timestamp::now(), None)
2726 .await?
2727 .and_then(|row| row.time_expires()),
2728 Some(old_deadline)
2729 );
2730
2731 let requested = Timestamp::now() + tti;
2732 assert_eq!(
2733 backend
2734 .compare_and_update(
2735 &id,
2736 Some(&id),
2737 TieredUpdate::SetExpiry(ExpiryTarget::At(requested).into()),
2738 Timestamp::now()
2739 )
2740 .await?,
2741 SetExpiryResponse::Satisfied(requested)
2742 );
2743
2744 let new_deadline = match backend
2746 .read_row(&path, "test-verify", Timestamp::now(), None)
2747 .await?
2748 {
2749 Some(RowData::Tombstone { time_expires, .. }) => time_expires.unwrap(),
2750 _ => panic!("expected tombstone row after extension"),
2751 };
2752
2753 assert!(
2754 new_deadline > old_deadline,
2755 "explicit extension should extend tombstone expiry: {old_deadline:?} -> {new_deadline:?}"
2756 );
2757
2758 Ok(())
2759 }
2760
2761 #[tokio::test]
2766 async fn test_legacy_tombstone_conditional_ops() -> Result<()> {
2767 let backend = create_test_backend().await?;
2768
2769 let id = make_id();
2771 write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2772 let t_opt = backend
2773 .put_non_tombstone(&id, &Metadata::default(), Bytes::new(), Timestamp::now())
2774 .await?;
2775 assert_eq!(t_opt.map(|t| t.target).as_ref(), Some(&id));
2776
2777 let id = make_id();
2779 write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2780 let t_opt = backend.delete_non_tombstone(&id, Timestamp::now()).await?;
2781 assert_eq!(t_opt.map(|t| t.target).as_ref(), Some(&id));
2782
2783 let id = make_id();
2785 write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2786 let deleted = backend
2787 .compare_and_write(&id, Some(&id), TieredWrite::Delete, Timestamp::now())
2788 .await?;
2789 assert!(
2790 deleted,
2791 "CAS-delete must succeed on legacy-metadata tombstone"
2792 );
2793 assert!(matches!(
2794 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2795 TieredMetadata::NotFound
2796 ));
2797
2798 let id = make_id();
2800 write_empty_redirect_tombstone(&backend, &id).await?;
2801 let deleted = backend
2802 .compare_and_write(&id, Some(&id), TieredWrite::Delete, Timestamp::now())
2803 .await?;
2804 assert!(
2805 deleted,
2806 "CAS-delete must succeed on empty-redirect tombstone"
2807 );
2808 assert!(matches!(
2809 backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2810 TieredMetadata::NotFound
2811 ));
2812
2813 Ok(())
2814 }
2815
2816 #[tokio::test]
2818 async fn test_empty_redirect_falls_back_to_hv_id() -> Result<()> {
2819 let backend = create_test_backend().await?;
2820 let id = make_id();
2821
2822 write_empty_redirect_tombstone(&backend, &id).await?;
2823 match backend.get_tiered_metadata(&id, Timestamp::now()).await? {
2824 TieredMetadata::Tombstone(t) => assert_eq!(t.target, id, "must fall back to hv_id"),
2825 other => panic!("expected tombstone, got {other:?}"),
2826 }
2827
2828 Ok(())
2829 }
2830
2831 #[tokio::test]
2836 async fn test_cas_create_tombstone_over_expired() -> Result<()> {
2837 let backend = create_test_backend().await?;
2838
2839 let id = make_id();
2840 let old_lt_id = ObjectId::random(id.context().clone());
2841 let old_tombstone = Tombstone {
2842 target: old_lt_id,
2843 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2844 };
2845 create_tombstone(&backend, &id, &old_tombstone).await?;
2846
2847 let new_lt_id = ObjectId::random(id.context().clone());
2848 let new_tombstone = Tombstone {
2849 target: new_lt_id.clone(),
2850 time_expires: Some(Timestamp::now() + Duration::from_hours(1)),
2851 };
2852 let committed = backend
2853 .compare_and_write(
2854 &id,
2855 None,
2856 TieredWrite::Tombstone(new_tombstone),
2857 Timestamp::now(),
2858 )
2859 .await?;
2860 assert!(
2861 committed,
2862 "CAS with current=None must succeed over an expired tombstone"
2863 );
2864
2865 let TieredMetadata::Tombstone(t) =
2866 backend.get_tiered_metadata(&id, Timestamp::now()).await?
2867 else {
2868 panic!("expected new tombstone to be readable");
2869 };
2870 assert_eq!(t.target, new_lt_id);
2871
2872 Ok(())
2873 }
2874
2875 #[tokio::test]
2878 async fn test_put_non_tombstone_over_expired() -> Result<()> {
2879 let backend = create_test_backend().await?;
2880
2881 let id = make_id();
2882 let lt_id = ObjectId::random(id.context().clone());
2883 let tombstone = Tombstone {
2884 target: lt_id,
2885 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2886 };
2887 create_tombstone(&backend, &id, &tombstone).await?;
2888
2889 let result = backend
2890 .put_non_tombstone(
2891 &id,
2892 &Metadata::default(),
2893 Bytes::from_static(b"data"),
2894 Timestamp::now(),
2895 )
2896 .await?;
2897 assert_eq!(
2898 result, None,
2899 "put_non_tombstone must succeed (return None) over an expired tombstone"
2900 );
2901
2902 let (_, _, stream) = backend
2903 .get_object(&id, Timestamp::now(), None)
2904 .await?
2905 .unwrap();
2906 assert_eq!(&stream::read_to_vec(stream).await?, b"data");
2907
2908 Ok(())
2909 }
2910
2911 async fn put_range_test_object(backend: &BigTableBackend) -> Result<ObjectId> {
2914 let id = make_id();
2915 let metadata = Metadata {
2916 content_type: "text/plain".into(),
2917 ..Default::default()
2918 };
2919 let payload = b"Hello, range requests!";
2920 backend
2921 .put_object(
2922 &id,
2923 &metadata,
2924 stream::single(payload.as_slice()),
2925 Timestamp::now(),
2926 )
2927 .await?;
2928 Ok(id)
2929 }
2930
2931 #[tokio::test]
2932 async fn get_object_range_bounded() -> Result<()> {
2933 let backend = create_test_backend().await?;
2934 let id = put_range_test_object(&backend).await?;
2935
2936 let (_, content_range, stream) = backend
2937 .get_object(&id, Timestamp::now(), Some(ByteRange::Bounded(7, 11)))
2938 .await?
2939 .unwrap();
2940 let data = stream::read_to_vec(stream).await?;
2941 assert_eq!(&data, b"range");
2942
2943 let content_range = content_range.unwrap();
2944 assert_eq!(content_range.start, 7);
2945 assert_eq!(content_range.end, 11);
2946 assert_eq!(content_range.total, 22);
2947
2948 Ok(())
2949 }
2950
2951 #[tokio::test]
2952 async fn get_object_range_from() -> Result<()> {
2953 let backend = create_test_backend().await?;
2954 let id = put_range_test_object(&backend).await?;
2955
2956 let (_, content_range, stream) = backend
2957 .get_object(&id, Timestamp::now(), Some(ByteRange::From(7)))
2958 .await?
2959 .unwrap();
2960 let data = stream::read_to_vec(stream).await?;
2961 assert_eq!(&data, b"range requests!");
2962
2963 let content_range = content_range.unwrap();
2964 assert_eq!(content_range.start, 7);
2965 assert_eq!(content_range.end, 21);
2966 assert_eq!(content_range.total, 22);
2967
2968 Ok(())
2969 }
2970
2971 #[tokio::test]
2972 async fn get_object_range_last() -> Result<()> {
2973 let backend = create_test_backend().await?;
2974 let id = put_range_test_object(&backend).await?;
2975
2976 let (_, content_range, stream) = backend
2977 .get_object(&id, Timestamp::now(), Some(ByteRange::Last(9)))
2978 .await?
2979 .unwrap();
2980 let data = stream::read_to_vec(stream).await?;
2981 assert_eq!(&data, b"requests!");
2982
2983 let content_range = content_range.unwrap();
2984 assert_eq!(content_range.start, 13);
2985 assert_eq!(content_range.end, 21);
2986 assert_eq!(content_range.total, 22);
2987
2988 Ok(())
2989 }
2990
2991 #[tokio::test]
2992 async fn get_object_range_unsatisfiable() -> Result<()> {
2993 let backend = create_test_backend().await?;
2994 let id = put_range_test_object(&backend).await?;
2995
2996 match backend
2997 .get_object(&id, Timestamp::now(), Some(ByteRange::From(100)))
2998 .await
2999 {
3000 Err(error) if matches!(error.kind(), ErrorKind::RangeNotSatisfiable { total: 22 }) => {}
3001 Ok(_) => panic!("expected RangeNotSatisfiable, got Ok"),
3002 Err(e) => panic!("expected RangeNotSatisfiable, got {e:?}"),
3003 }
3004
3005 Ok(())
3006 }
3007
3008 #[tokio::test]
3009 async fn get_object_no_range_returns_full_payload() -> Result<()> {
3010 let backend = create_test_backend().await?;
3011 let id = put_range_test_object(&backend).await?;
3012
3013 let (_, content_range, stream) = backend
3014 .get_object(&id, Timestamp::now(), None)
3015 .await?
3016 .unwrap();
3017 let data = stream::read_to_vec(stream).await?;
3018 assert_eq!(&data, b"Hello, range requests!");
3019 assert!(content_range.is_none());
3020
3021 Ok(())
3022 }
3023
3024 #[test]
3025 fn row_size_counts_the_key_and_every_cell() {
3026 let path = b"attachments/org.1/objects/abc";
3027 let (mutations, size) =
3028 object_mutations(path, Metadata::default(), b"0123456789".to_vec()).unwrap();
3029
3030 let stamped = Metadata {
3034 size: Some(10),
3035 ..Default::default()
3036 };
3037 let metadata_len = serde_json::to_vec(&stamped).unwrap().len();
3038 let expected = (path.len() + 10 + metadata_len) as u64;
3039
3040 assert_eq!(row_size(path, &mutations), expected);
3041 assert_eq!(size, expected, "the size handed back matches the mutations");
3042 }
3043
3044 #[test]
3045 fn row_size_is_nonzero_for_tombstones() {
3046 let path = b"attachments/org.1/objects/abc";
3047 let time_expires = Timestamp::now() + Duration::from_secs(60);
3048 let tombstone = Tombstone {
3049 target: ObjectId::from_storage_path("attachments/org.1/objects/abc/0199").unwrap(),
3050 time_expires: Some(time_expires),
3051 };
3052 let mutations = tombstone_mutations(&tombstone);
3053
3054 assert_eq!(mutations.len(), 2);
3055 let set_cell = mutations[1].mutation.as_ref().unwrap();
3056 let mutation::Mutation::SetCell(set_cell) = set_cell else {
3057 panic!("expected redirect SetCell mutation");
3058 };
3059 assert_eq!(set_cell.family_name, FAMILY_GC);
3060 assert_eq!(set_cell.column_qualifier, COLUMN_REDIRECT);
3061 assert_eq!(set_cell.timestamp_micros, time_expires.as_micros() as i64);
3062 assert!(row_size(path, &mutations) > path.len() as u64);
3063 }
3064
3065 #[cfg(feature = "storage-cogs")]
3066 #[tokio::test]
3067 async fn change_stream_reports_writes_and_deletes() -> Result<()> {
3068 let (backend, producer) = create_test_backend_with_change_stream().await?;
3069 let id = make_id();
3070 let metadata = Metadata {
3071 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(3600)),
3072 time_expires: Some(Timestamp::now() + Duration::from_secs(3600)),
3073 ..Default::default()
3074 };
3075
3076 backend
3077 .put_object(
3078 &id,
3079 &metadata,
3080 stream::single::<crate::stream::ClientError>(b"hello".to_vec()),
3081 Timestamp::now(),
3082 )
3083 .await?;
3084 backend.delete_object(&id, Timestamp::now()).await?;
3085
3086 let records = producer.records();
3087 assert_eq!(records.len(), 2);
3088
3089 assert_eq!(records[0].op_type, OpType::Write);
3090 assert_eq!(records[0].app_feature, "testing");
3091 assert_eq!(records[0].shared_resource_id, "bigtable_objectstore");
3092 assert!(records[0].size.unwrap() > b"hello".len() as u64);
3094 assert!(records[0].expiration_time.is_some());
3095
3096 assert_eq!(records[1].op_type, OpType::Delete);
3097 assert_eq!(records[1].size, None);
3098 assert_eq!(records[1].record_id, records[0].record_id);
3099
3100 Ok(())
3101 }
3102
3103 #[cfg(feature = "storage-cogs")]
3104 #[tokio::test]
3105 async fn change_stream_reports_upload_markers() -> Result<()> {
3106 let (backend, producer) = create_test_backend_with_change_stream().await?;
3107 let revision = new_test_revision(&make_id());
3108 let now = Timestamp::now();
3109 let expires_at = now + Duration::from_secs(3600);
3110
3111 backend.create_upload_marker(&revision, expires_at).await?;
3112 assert!(backend.delete_upload_marker(&revision, now).await?);
3113 assert!(!backend.delete_upload_marker(&revision, now).await?);
3114
3115 let records = producer.records();
3116 assert_eq!(records.len(), 2);
3117 assert_eq!(records[0].op_type, OpType::Write);
3118 assert_eq!(
3119 records[0].size,
3120 Some(revision.as_upload_path().to_string().len() as u64 + 1)
3121 );
3122 assert_eq!(
3123 records[0].expiration_time,
3124 Some(expires_at.as_micros() as i64)
3125 );
3126 assert_eq!(records[1].op_type, OpType::Delete);
3127 assert_eq!(records[1].record_id, records[0].record_id);
3128 Ok(())
3129 }
3130
3131 #[cfg(feature = "storage-cogs")]
3132 #[tokio::test]
3133 async fn delete_non_tombstone_reclaims_expired_rows() -> Result<()> {
3134 let (backend, producer) = create_test_backend_with_change_stream().await?;
3135
3136 let id = make_id();
3139 let tombstone = Tombstone {
3140 target: ObjectId::random(id.context().clone()),
3141 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
3142 };
3143 create_tombstone(&backend, &id, &tombstone).await?;
3144 assert_eq!(
3145 backend.delete_non_tombstone(&id, Timestamp::now()).await?,
3146 None,
3147 "an expired tombstone must not be returned to the caller"
3148 );
3149 let records = producer.records();
3150 assert_eq!(records.len(), 1, "the expired tombstone must be reclaimed");
3151 assert_eq!(records[0].op_type, OpType::Delete);
3152
3153 let id = make_id();
3155 let metadata = Metadata {
3156 expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
3157 time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
3158 ..Default::default()
3159 };
3160 create_object(&backend, &id, &metadata, b"gone", Timestamp::now()).await?;
3161 producer.clear();
3162 assert_eq!(
3163 backend.delete_non_tombstone(&id, Timestamp::now()).await?,
3164 None
3165 );
3166 let records = producer.records();
3167 assert_eq!(records.len(), 1, "the object row must be reclaimed");
3168 assert_eq!(records[0].op_type, OpType::Delete);
3169
3170 Ok(())
3171 }
3172
3173 #[cfg(feature = "storage-cogs")]
3174 #[tokio::test]
3175 async fn change_stream_reports_tombstone_rows() -> Result<()> {
3176 let (backend, producer) = create_test_backend_with_change_stream().await?;
3177 let id = make_id();
3178 let target = new_test_revision(&id);
3179
3180 let tombstone = Tombstone {
3181 target: target.clone(),
3182 time_expires: Some(Timestamp::now() + Duration::from_secs(3600)),
3183 };
3184 let written = backend
3185 .compare_and_write(
3186 &id,
3187 None,
3188 TieredWrite::Tombstone(tombstone),
3189 Timestamp::now(),
3190 )
3191 .await?;
3192 assert!(written);
3193
3194 let records = producer.records();
3195 assert_eq!(records.len(), 1);
3196 assert_eq!(records[0].op_type, OpType::Write);
3197 assert!(
3198 records[0].size.unwrap() > 0,
3199 "tombstone rows occupy storage and must not report zero"
3200 );
3201 assert!(records[0].expiration_time.is_some());
3202
3203 Ok(())
3204 }
3205
3206 #[cfg(feature = "storage-cogs")]
3207 #[tokio::test]
3208 async fn change_stream_reports_expiry_extension_as_an_update() -> Result<()> {
3209 let (backend, producer) = create_test_backend_with_change_stream().await?;
3210 let id = make_id();
3211 let metadata = Metadata {
3212 expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(3600)),
3213 time_expires: Some(Timestamp::now() + Duration::from_secs(1)),
3214 ..Default::default()
3215 };
3216
3217 backend
3218 .put_object(
3219 &id,
3220 &metadata,
3221 stream::single::<crate::stream::ClientError>(b"hello".to_vec()),
3222 Timestamp::now(),
3223 )
3224 .await?;
3225 producer.clear();
3226
3227 backend
3228 .set_expiry(
3229 &id,
3230 ExpiryTarget::At(Timestamp::now() + Duration::from_secs(3600)).into(),
3231 Timestamp::now(),
3232 )
3233 .await?;
3234
3235 let records = producer.records();
3236 assert_eq!(records.len(), 1, "expected exactly one extension report");
3237 assert_eq!(records[0].op_type, OpType::Update);
3238 assert_eq!(
3239 records[0].size, None,
3240 "an extension does not change the size"
3241 );
3242 assert!(records[0].expiration_time.is_some());
3243
3244 Ok(())
3245 }
3246
3247 #[cfg(feature = "storage-cogs")]
3248 fn new_test_revision(id: &ObjectId) -> ObjectId {
3249 ObjectId {
3250 context: id.context.clone(),
3251 key: format!("{}/{}", id.key, uuid::Uuid::now_v7()),
3252 }
3253 }
3254}