Skip to main content

objectstore_service/backend/
bigtable.rs

1//! BigTable backend for high-volume, low-latency storage of small objects.
2//!
3//! # Row Format
4//!
5//! Each row key is the object's storage path. A row contains either an **object** or a
6//! **tombstone** — never both. The two layouts are mutually exclusive and distinguished by
7//! column presence:
8//!
9//! | Column | Family    | Content                     | Present when       |
10//! |--------|-----------|-----------------------------|--------------------|
11//! | `p`    | `fg`/`fm` | Compressed payload bytes    | Object row only    |
12//! | `m`    | `fg`/`fm` | [`Metadata`] JSON           | Object row only    |
13//! | `r`    | `fg`/`fm` | Redirect path to LT storage | Tombstone row only |
14//! | `t`    | `fg`/`fm` | [`Tombstone`] metadata JSON | Tombstone row only |
15//!
16//! The `r` column signals a tombstone row: its **value** is the long-term `ObjectId`
17//! serialized via `as_storage_path()`. Callers can resolve the LT object directly from the
18//! `r` value without reconstructing it from the row key.
19//!
20//! `p`/`m` and `r`/`t` are mutually exclusive. Every write begins with a `DeleteFromRow`
21//! mutation that clears all columns before writing the new cells, so mixed rows cannot exist.
22//!
23//! ## Legacy Tombstone Format
24//!
25//! Tombstones written before the `r`/`t` column layout used the object-row format with an
26//! empty `p` column and `"is_redirect_tombstone": true` in the `m` JSON. Both formats are
27//! supported for reading. A `bigtable.legacy_tombstone_read` metric is emitted on each legacy
28//! read. Legacy tombstones expire naturally by TTL/GC; TTI bumps transparently upgrade them
29//! to the new format.
30
31use std::fmt;
32use std::future::Future;
33use std::sync::Arc;
34use std::time::{Duration, SystemTime};
35
36use bigtable_rs::bigtable::{BigTableConnection, Error as BigTableError, RowCell};
37use bigtable_rs::google::bigtable::v2::{self, mutation};
38use bytes::Bytes;
39use futures_util::TryStreamExt;
40use objectstore_types::metadata::{ExpirationPolicy, Metadata};
41use objectstore_types::range::{ByteRange, ContentRange};
42use serde::{Deserialize, Serialize};
43use tonic::Code;
44use tracing::Instrument;
45
46use crate::backend::common::{
47    Backend, DeleteResponse, GetResponse, HighVolumeBackend, MetadataResponse, PutResponse,
48    TieredGet, TieredMetadata, TieredWrite, Tombstone,
49};
50use crate::error::{Error, Result};
51use crate::gcp_auth::PrefetchingTokenProvider;
52use crate::id::ObjectId;
53use crate::stream::{ChunkedBytes, ClientStream};
54
55/// Configuration for [`BigTableBackend`].
56///
57/// Stores objects in [Google Cloud Bigtable], a NoSQL wide-column database optimized for
58/// high-throughput, low-latency workloads with small objects. Authentication uses Application
59/// Default Credentials (ADC).
60///
61/// **Note**: The table must be pre-created with the following column families:
62/// - `fg`: timestamp-based garbage collection (`maxage=1s`)
63/// - `fm`: manual garbage collection (`no GC policy`)
64///
65/// [Google Cloud Bigtable]: https://cloud.google.com/bigtable
66///
67/// # Example
68///
69/// ```yaml
70/// storage:
71///   type: bigtable
72///   project_id: my-project
73///   instance_name: objectstore
74///   table_name: objectstore
75/// ```
76#[derive(Debug, Clone, Deserialize, Serialize)]
77pub struct BigTableConfig {
78    /// Optional custom Bigtable endpoint.
79    ///
80    /// Useful for testing with emulators. If `None`, uses the default Bigtable endpoint.
81    ///
82    /// # Default
83    ///
84    /// `None` (uses default Bigtable endpoint)
85    ///
86    /// # Environment Variables
87    ///
88    /// - `OS__STORAGE__TYPE=bigtable`
89    /// - `OS__STORAGE__ENDPOINT=localhost:8086` (optional)
90    pub endpoint: Option<String>,
91
92    /// GCP project ID.
93    ///
94    /// The Google project ID (not project number) containing the Bigtable instance.
95    ///
96    /// # Environment Variables
97    ///
98    /// - `OS__STORAGE__PROJECT_ID=my-project`
99    pub project_id: String,
100
101    /// Bigtable instance name.
102    ///
103    /// # Environment Variables
104    ///
105    /// - `OS__STORAGE__INSTANCE_NAME=my-instance`
106    pub instance_name: String,
107
108    /// Bigtable table name.
109    ///
110    /// The table must exist before starting the server.
111    ///
112    /// # Environment Variables
113    ///
114    /// - `OS__STORAGE__TABLE_NAME=objectstore`
115    pub table_name: String,
116
117    /// Optional number of connections to maintain to Bigtable.
118    ///
119    /// # Default
120    ///
121    /// `None` (defaults to 1)
122    ///
123    /// # Environment Variables
124    ///
125    /// - `OS__STORAGE__CONNECTIONS=16` (optional)
126    pub connections: Option<usize>,
127}
128
129/// Connection timeout used for the initial connection to Bigtable.
130const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
131/// Maximum age for connections (GRPC channels) to Bigtable, after which they will be swapped with
132/// new ones in the background.
133/// This is intended to avoid latency spikes that could occur every hour or so, when the server
134/// closes long standing connections ([source](https://web.archive.org/web/20260211140930/https://docs.cloud.google.com/bigtable/docs/performance#cold-starts:~:text=return%20an%20error.-,Cold%20start,-at%20client%20initialization)).
135/// `tonic` already handles reconnections transparently, but lazily, meaning that the first requests
136/// that attempt to use a certain channel after the server has closed it will pay the cost of the
137/// reconnection, resulting in increased latency for those requests.
138const MAX_CHANNEL_AGE: Option<Duration> = Some(Duration::from_mins(50));
139/// Time to debounce bumping an object with configured TTI.
140const TTI_DEBOUNCE: Duration = Duration::from_hours(24);
141/// Permission scopes required for accessing the BigTable data API.
142const TOKEN_SCOPES: &[&str] = &["https://www.googleapis.com/auth/bigtable.data"];
143
144/// How often to retry failed requests.
145const REQUEST_RETRY_COUNT: usize = 2;
146/// How many times to retry a CAS mutation before giving up and returning an error.
147const CAS_RETRY_COUNT: usize = 3;
148
149/// Column that stores the raw payload (compressed).
150const COLUMN_PAYLOAD: &[u8] = b"p";
151/// Column that stores metadata in JSON.
152const COLUMN_METADATA: &[u8] = b"m";
153/// Column that stores the redirect path for tombstone rows.
154const COLUMN_REDIRECT: &[u8] = b"r";
155/// Column that stores [`TombstoneMeta`] JSON for tombstone rows.
156const COLUMN_TOMBSTONE_META: &[u8] = b"t";
157/// Regex to match all non-payload columns (`m`, `r`, `t`) for metadata-only reads.
158const FILTER_META: &[u8] = b"^[mrt]$";
159
160/// Column family that uses timestamp-based garbage collection.
161///
162/// We require a GC rule on this family to automatically delete rows.
163/// See: <https://cloud.google.com/bigtable/docs/gc-cell-level>
164const FAMILY_GC: &str = "fg";
165/// Column family that uses manual garbage collection.
166const FAMILY_MANUAL: &str = "fm";
167
168/// BigTable storage backend for high-volume, low-latency object storage.
169pub struct BigTableBackend {
170    bigtable: BigTableConnection,
171
172    instance_path: String,
173    table_path: String,
174    table_name: String,
175}
176
177impl fmt::Debug for BigTableBackend {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        f.debug_struct("BigTableBackend")
180            .field("instance_path", &self.instance_path)
181            .field("table_path", &self.table_path)
182            .field("table_name", &self.table_name)
183            .finish_non_exhaustive()
184    }
185}
186
187/// Creates a row filter that matches a single column by exact qualifier.
188fn column_filter(column: &[u8]) -> v2::RowFilter {
189    v2::RowFilter {
190        filter: Some(v2::row_filter::Filter::ColumnQualifierRegexFilter(
191            [b"^", column, b"$"].concat(),
192        )),
193    }
194}
195
196/// Creates a row filter matching the legacy tombstone format: `m` column JSON starts with
197/// `{"is_redirect_tombstone":true`.
198///
199/// After legacy tombstones expire naturally this filter becomes dead code in both callers.
200fn legacy_tombstone_filter() -> v2::RowFilter {
201    v2::RowFilter {
202        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
203            filters: vec![
204                column_filter(COLUMN_METADATA),
205                v2::RowFilter {
206                    filter: Some(v2::row_filter::Filter::ValueRegexFilter(
207                        b"^\\{\"is_redirect_tombstone\":true[,}].*".to_vec(),
208                    )),
209                },
210            ],
211        })),
212    }
213}
214
215/// Wraps `inner` so that it only matches live (non-expired) cells.
216fn live_row_filter(inner: v2::RowFilter) -> v2::RowFilter {
217    let now_micros = time_to_micros_saturating(SystemTime::now());
218
219    v2::RowFilter {
220        filter: Some(v2::row_filter::Filter::Interleave(
221            v2::row_filter::Interleave {
222                filters: vec![
223                    // Manual family: never expires.
224                    v2::RowFilter {
225                        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
226                            filters: vec![
227                                v2::RowFilter {
228                                    filter: Some(v2::row_filter::Filter::FamilyNameRegexFilter(
229                                        format!("^{FAMILY_MANUAL}$"),
230                                    )),
231                                },
232                                inner.clone(),
233                            ],
234                        })),
235                    },
236                    // GC family: only match non-expired cells.
237                    v2::RowFilter {
238                        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
239                            filters: vec![
240                                v2::RowFilter {
241                                    filter: Some(v2::row_filter::Filter::FamilyNameRegexFilter(
242                                        format!("^{FAMILY_GC}$"),
243                                    )),
244                                },
245                                v2::RowFilter {
246                                    filter: Some(v2::row_filter::Filter::TimestampRangeFilter(
247                                        v2::TimestampRange {
248                                            start_timestamp_micros: now_micros,
249                                            end_timestamp_micros: 0,
250                                        },
251                                    )),
252                                },
253                                inner,
254                            ],
255                        })),
256                    },
257                ],
258            },
259        )),
260    }
261}
262
263/// Builds a raw row filter that matches any tombstone row, new- or legacy-format.
264///
265/// New format: presence of the `r` column.
266/// Legacy format: `is_redirect_tombstone: true` in the `m` column JSON.
267///
268/// After legacy tombstones expire naturally this simplifies to just
269/// `column_filter(COLUMN_REDIRECT)`.
270fn tombstone_filter() -> v2::RowFilter {
271    let filter = v2::RowFilter {
272        filter: Some(v2::row_filter::Filter::Interleave(
273            v2::row_filter::Interleave {
274                filters: vec![column_filter(COLUMN_REDIRECT), legacy_tombstone_filter()],
275            },
276        )),
277    };
278    live_row_filter(filter)
279}
280
281/// Returns a [`MutatePredicate`] that matches any tombstone row.
282///
283/// Mutations run only when no tombstone is present (`predicate_matched == false`).
284/// Used by [`BigTableBackend::put_non_tombstone`], [`BigTableBackend::delete_non_tombstone`],
285/// and [`BigTableBackend::compare_and_write`] as the `CheckAndMutateRow` predicate.
286fn tombstone_predicate() -> MutatePredicate {
287    MutatePredicate::Exclude(tombstone_filter())
288}
289
290/// Builds an anchored regex pattern (`^…$`) that matches `value` literally.
291///
292/// Uses [`regex::escape`] so that metacharacters in storage paths (`.`, `/`, etc.)
293/// are treated as literal bytes.
294fn exact_value_regex(value: &str) -> Vec<u8> {
295    format!("^{}$", regex::escape(value)).into_bytes()
296}
297
298/// Matches tombstones whose redirect resolves to `target`.
299///
300/// ## Predicate Matches
301///
302/// Must be used with `true_mutations` and `predicate_matched == true`.
303///
304/// ## Details
305///
306/// Always includes an exact match on the `r` (redirect) column:
307/// - Chain: `r` column present AND value == `target` storage path
308///
309/// When `target == own_id` (the caller expects a legacy identity redirect), the
310/// exact match is wrapped in an Interleave with two additional fallbacks:
311/// - Chain: `r` column present AND value == `b""` (empty-sentinel written before the redirect
312///   column stored the path)
313/// - Chain: `m` column present AND value matches `{"is_redirect_tombstone":true...}` regex
314///   (legacy metadata format predating the dedicated `r` column)
315fn redirect_target_filter(target: &ObjectId, own_id: &ObjectId) -> v2::RowFilter {
316    let target_path = exact_value_regex(&target.as_storage_path().to_string());
317
318    let exact_match = v2::RowFilter {
319        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
320            filters: vec![
321                column_filter(COLUMN_REDIRECT),
322                v2::RowFilter {
323                    filter: Some(v2::row_filter::Filter::ValueRegexFilter(target_path)),
324                },
325            ],
326        })),
327    };
328
329    if target != own_id {
330        return live_row_filter(exact_match);
331    }
332
333    let empty_redirect_match = v2::RowFilter {
334        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
335            filters: vec![
336                column_filter(COLUMN_REDIRECT),
337                v2::RowFilter {
338                    filter: Some(v2::row_filter::Filter::ValueRegexFilter(b"^$".to_vec())),
339                },
340            ],
341        })),
342    };
343
344    // Also match legacy tombstones that resolve to the HV id:
345    // - empty `r` value (written before the redirect column stored the path)
346    // - legacy `m` column format (`is_redirect_tombstone: true`)
347    let filter = v2::RowFilter {
348        filter: Some(v2::row_filter::Filter::Interleave(
349            v2::row_filter::Interleave {
350                filters: vec![exact_match, empty_redirect_match, legacy_tombstone_filter()],
351            },
352        )),
353    };
354    live_row_filter(filter)
355}
356
357/// Returns a [`MutatePredicate`] that matches tombstones whose redirect resolves to either `old` or `new`.
358///
359/// Mutations run only when the predicate matches (`predicate_matched == true`):
360/// equivalent to `t == old || t == new`. Built as an Interleave of two
361/// [`redirect_target_filter`] calls — yields cells iff at least one branch matches.
362/// An absent row or non-tombstone row yields 0 cells, so `predicate_matched = false` (conflict).
363fn update_predicate(old: &ObjectId, new: &ObjectId, own_id: &ObjectId) -> MutatePredicate {
364    MutatePredicate::Include(v2::RowFilter {
365        filter: Some(v2::row_filter::Filter::Interleave(
366            v2::row_filter::Interleave {
367                filters: vec![
368                    redirect_target_filter(old, own_id),
369                    redirect_target_filter(new, own_id),
370                ],
371            },
372        )),
373    })
374}
375
376/// Returns a [`MutatePredicate`] that matches rows where no conflicting tombstone exists.
377///
378/// Mutations run only when the row is conflict-free (`predicate_matched == false`):
379/// no tombstone is present, or the tombstone's redirect already points to `target`.
380///
381/// Built as an inverted `Condition` filter:
382/// - Predicate: [`redirect_target_filter`]`(target)` — tombstone already points to `target`?
383/// - True branch: `BlockAllFilter` → 0 cells (already at target, safe state).
384/// - False branch: [`tombstone_filter`] → 0 cells when no tombstone exists.
385///
386/// Both safe states yield 0 cells, so `predicate_matched = false` in both cases.
387fn optional_target_predicate(target: &ObjectId, own_id: &ObjectId) -> MutatePredicate {
388    MutatePredicate::Exclude(v2::RowFilter {
389        filter: Some(v2::row_filter::Filter::Condition(Box::new(
390            v2::row_filter::Condition {
391                predicate_filter: Some(Box::new(redirect_target_filter(target, own_id))),
392                true_filter: Some(Box::new(v2::RowFilter {
393                    filter: Some(v2::row_filter::Filter::BlockAllFilter(true)),
394                })),
395                false_filter: Some(Box::new(tombstone_filter())),
396            },
397        ))),
398    })
399}
400
401/// The condition under which a [`BigTableBackend::check_and_mutate`] write proceeds.
402///
403/// Each variant pairs a row filter with the state that makes the write safe:
404/// `Include` writes when the row matches; `Exclude` writes when it does not.
405#[derive(Clone, Debug)]
406enum MutatePredicate {
407    /// Write proceeds when the filter matches the row.
408    ///
409    /// Mutations run in `true_mutations`; succeeds when `predicate_matched == true`.
410    Include(v2::RowFilter),
411    /// Write proceeds when the filter does not match the row.
412    ///
413    /// Mutations run in `false_mutations`; succeeds when `predicate_matched == false`.
414    Exclude(v2::RowFilter),
415}
416
417/// Creates a row filter that reads all non-payload columns (`m`, `r`, `t`).
418///
419/// Used by metadata-only reads to avoid fetching the (potentially large) payload column
420/// while still being able to detect both new- and legacy-format tombstones.
421fn metadata_filter() -> v2::RowFilter {
422    v2::RowFilter {
423        filter: Some(v2::row_filter::Filter::ColumnQualifierRegexFilter(
424            FILTER_META.to_owned(),
425        )),
426    }
427}
428
429fn mutation(mutation: mutation::Mutation) -> v2::Mutation {
430    v2::Mutation {
431        mutation: Some(mutation),
432    }
433}
434
435/// Creates a `DeleteFromRow` mutation wrapped in the outer [`v2::Mutation`] envelope.
436fn delete_row_mutation() -> v2::Mutation {
437    mutation(mutation::Mutation::DeleteFromRow(
438        mutation::DeleteFromRow {},
439    ))
440}
441
442/// Returns a clone of `metadata` with `time_expires` refreshed for a TTI bump.
443///
444/// [`object_mutations`] persists `time_expires` verbatim, so the bumped deadline must be applied
445/// to the metadata before rewriting the row.
446fn bumped_tti_metadata(metadata: &Metadata) -> Metadata {
447    let mut metadata = metadata.clone();
448    metadata.time_expires = metadata
449        .expiration_policy
450        .expires_in()
451        .map(|tti| SystemTime::now() + tti);
452    metadata
453}
454
455/// Builds the three mutations that write an object row: clear existing data,
456/// then set the payload and metadata cells.
457///
458/// Used by both [`BigTableBackend::put_row`] (unconditional write) and
459/// [`BigTableBackend::put_non_tombstone`] (conditional write).
460fn object_mutations(mut metadata: Metadata, payload: Vec<u8>) -> Result<[v2::Mutation; 3]> {
461    let (family, timestamp_micros) = match metadata.time_expires {
462        None => (FAMILY_MANUAL, -1),
463        Some(deadline) => (FAMILY_GC, system_time_to_micros(deadline)?),
464    };
465
466    // Record the payload size in the metadata before persisting it.
467    metadata.size = Some(payload.len());
468
469    let metadata_bytes = serde_json::to_vec(&metadata)
470        .map_err(|cause| Error::serde("failed to serialize metadata", cause))?;
471
472    Ok([
473        // NB: We explicitly delete the row to clear metadata on overwrite.
474        delete_row_mutation(),
475        mutation(mutation::Mutation::SetCell(mutation::SetCell {
476            family_name: family.to_owned(),
477            column_qualifier: COLUMN_PAYLOAD.to_owned(),
478            timestamp_micros,
479            value: payload,
480        })),
481        mutation(mutation::Mutation::SetCell(mutation::SetCell {
482            family_name: family.to_owned(),
483            column_qualifier: COLUMN_METADATA.to_owned(),
484            timestamp_micros,
485            value: metadata_bytes,
486        })),
487    ])
488}
489
490/// Metadata carried by tombstone rows in the `t` (tombstone-meta) column.
491///
492/// Tombstone-specific metadata evolves independently of object [`Metadata`]. Only fields
493/// that are meaningful on tombstones are included here.
494#[derive(Clone, Debug, Default, Deserialize, Serialize)]
495struct TombstoneMeta {
496    /// Expiration policy for this tombstone.
497    ///
498    /// Skipped during serialization when set to [`ExpirationPolicy::Manual`].
499    #[serde(default, skip_serializing_if = "ExpirationPolicy::is_manual")]
500    expiration_policy: ExpirationPolicy,
501}
502
503/// Builds the three mutations that write a tombstone row: clear existing data,
504/// then set the redirect sentinel and tombstone-meta cells.
505///
506/// Used by both [`BigTableBackend::put_tombstone_row`] (unconditional write) and the
507/// TTI bump path in tiered reads.
508fn tombstone_mutations(tombstone: &Tombstone, now: SystemTime) -> Result<[v2::Mutation; 3]> {
509    let (family, timestamp_micros) = match tombstone.expiration_policy {
510        ExpirationPolicy::Manual => (FAMILY_MANUAL, -1),
511        ExpirationPolicy::TimeToLive(ttl) => (FAMILY_GC, ttl_to_micros(ttl, now)?),
512        ExpirationPolicy::TimeToIdle(tti) => (FAMILY_GC, ttl_to_micros(tti, now)?),
513    };
514
515    let tombstone_meta = TombstoneMeta {
516        expiration_policy: tombstone.expiration_policy,
517    };
518
519    Ok([
520        delete_row_mutation(),
521        mutation(mutation::Mutation::SetCell(mutation::SetCell {
522            family_name: family.to_owned(),
523            column_qualifier: COLUMN_REDIRECT.to_owned(),
524            timestamp_micros,
525            value: tombstone.target.as_storage_path().to_string().into_bytes(),
526        })),
527        mutation(mutation::Mutation::SetCell(mutation::SetCell {
528            family_name: family.to_owned(),
529            column_qualifier: COLUMN_TOMBSTONE_META.to_owned(),
530            timestamp_micros,
531            value: serde_json::to_vec(&tombstone_meta)
532                .map_err(|cause| Error::serde("failed to serialize tombstone", cause))?,
533        })),
534    ])
535}
536
537/// Subset of [`Metadata`] that indicates a row is a tombstone instead of a real object.
538///
539/// Used to construct [`RowData`].
540#[derive(Debug, Deserialize)]
541struct LegacyTombstoneMeta {
542    /// Internal redirect tombstone marker.
543    ///
544    /// When `true`, this object is a legacy tombstone. This implies:
545    ///  - the payload is empty
546    ///  - metadata other than the expiration policy is not meaningful
547    ///  - the `r` and `t` columns are not present
548    #[serde(default)]
549    is_redirect_tombstone: bool,
550
551    /// Expiration policy for this tombstone.
552    #[serde(default)]
553    expiration_policy: ExpirationPolicy,
554}
555
556/// Parsed data from a BigTable row's cells.
557enum RowData {
558    /// A regular object row with payload and metadata.
559    Object {
560        metadata: Metadata,
561        payload: Vec<u8>,
562    },
563    /// A tombstone row indicating the real payload lives on the long-term backend.
564    Tombstone {
565        target: Vec<u8>,
566        meta: TombstoneMeta,
567        time_expires: Option<SystemTime>,
568    },
569}
570
571impl RowData {
572    /// Parses a set of row cells into a [`RowData`].
573    ///
574    /// New-format tombstones are identified by the presence of the `r` column.
575    /// Legacy tombstones (written before the column migration) are identified by
576    /// `is_redirect_tombstone: true` in the `m` column JSON; a
577    /// `bigtable.legacy_tombstone_read` metric is emitted on each such read.
578    fn from_cells(cells: Vec<RowCell>) -> Result<Self> {
579        let mut metadata_opt: Option<Metadata> = None;
580        let mut tombstone_meta_opt: Option<TombstoneMeta> = None;
581        let mut redirect_detected = false;
582        let mut redirect_target = Vec::new();
583        let mut expire_at = None;
584        let mut payload = Vec::new();
585
586        for cell in cells {
587            // NB: All cells are written with the same timestamp; last write is safe.
588
589            // Only derive expiration from GC-family cells — manual-family cells
590            // use server-assigned timestamps that don't represent expiration.
591            if cell.family_name == FAMILY_GC {
592                expire_at = micros_to_time(cell.timestamp_micros);
593            }
594
595            match cell.qualifier.as_slice() {
596                COLUMN_REDIRECT => {
597                    redirect_detected = true;
598                    redirect_target = cell.value;
599                }
600                COLUMN_PAYLOAD => {
601                    payload = cell.value;
602                }
603                COLUMN_TOMBSTONE_META => {
604                    tombstone_meta_opt =
605                        Some(serde_json::from_slice(&cell.value).map_err(|cause| {
606                            Error::serde("failed to deserialize tombstone meta", cause)
607                        })?);
608                }
609                COLUMN_METADATA => {
610                    if let Ok(legacy_meta) =
611                        serde_json::from_slice::<LegacyTombstoneMeta>(&cell.value)
612                        && legacy_meta.is_redirect_tombstone
613                    {
614                        redirect_detected = true;
615                        objectstore_metrics::count!("bigtable.legacy_tombstone_read");
616                        tombstone_meta_opt = Some(TombstoneMeta {
617                            expiration_policy: legacy_meta.expiration_policy,
618                        });
619                    } else {
620                        metadata_opt =
621                            Some(serde_json::from_slice(&cell.value).map_err(|cause| {
622                                Error::serde("failed to deserialize metadata", cause)
623                            })?);
624                    }
625                }
626                _ => {}
627            }
628        }
629
630        Ok(if redirect_detected {
631            RowData::Tombstone {
632                target: redirect_target,
633                meta: tombstone_meta_opt.unwrap_or_default(),
634                time_expires: expire_at,
635            }
636        } else {
637            // Metadata may have been skipped during read - payload-only read for TTI bump.
638            let mut metadata = metadata_opt.unwrap_or_default();
639            metadata.time_expires = expire_at;
640            RowData::Object { metadata, payload }
641        })
642    }
643
644    /// Returns the expiration policy for this row, regardless of variant.
645    fn expiration_policy(&self) -> ExpirationPolicy {
646        match self {
647            RowData::Object { metadata, .. } => metadata.expiration_policy,
648            RowData::Tombstone { meta, .. } => meta.expiration_policy,
649        }
650    }
651
652    /// Returns the resolved expiration timestamp for this row, regardless of variant.
653    fn time_expires(&self) -> Option<SystemTime> {
654        match self {
655            RowData::Object { metadata, .. } => metadata.time_expires,
656            RowData::Tombstone { time_expires, .. } => *time_expires,
657        }
658    }
659
660    /// Returns `true` if this row is expired as of the given `time`.
661    ///
662    /// Only applies to rows with an expiration policy set.
663    fn expires_before(&self, time: SystemTime) -> bool {
664        self.expiration_policy().is_timeout() && self.time_expires().is_some_and(|ts| ts < time)
665    }
666
667    /// Returns `true` if this row's TTI deadline should be bumped.
668    fn needs_tti_bump(&self) -> bool {
669        matches!(
670            self.expiration_policy(),
671            ExpirationPolicy::TimeToIdle(tti) if self.expires_before(SystemTime::now() + tti - TTI_DEBOUNCE)
672        )
673    }
674}
675
676/// Parses the raw `r` column bytes into a redirect target [`ObjectId`].
677///
678/// For tombstones with an empty `r` value, falls back to the ID of the tombstone
679/// itself and emits a `bigtable.empty_redirect_read` metric so deployments can
680/// track when it is safe to remove the legacy empty-value code path.
681fn parse_redirect_target(redirect_path: &[u8], tombstone_id: &ObjectId) -> Result<ObjectId> {
682    if redirect_path.is_empty() {
683        objectstore_metrics::count!("bigtable.empty_redirect_read");
684        Ok(tombstone_id.clone())
685    } else {
686        let redirect_str = std::str::from_utf8(redirect_path)
687            .map_err(|_| Error::generic("invalid UTF-8 in redirect path"))?;
688        ObjectId::from_storage_path(redirect_str)
689            .ok_or_else(|| Error::generic("corrupt redirect path"))
690    }
691}
692
693impl BigTableBackend {
694    /// Creates a new [`BigTableBackend`] from the given `config`.
695    ///
696    /// Pass an `endpoint` in the config to connect to a local emulator; omit it to use real GCP
697    /// credentials. `connections` controls the gRPC connection pool size (defaults to 1).
698    pub async fn new(config: BigTableConfig) -> anyhow::Result<Self> {
699        let BigTableConfig {
700            endpoint,
701            project_id,
702            instance_name,
703            table_name,
704            connections,
705        } = config;
706
707        let bigtable = if let Some(ref endpoint) = endpoint {
708            BigTableConnection::new_with_emulator(
709                endpoint,
710                &project_id,
711                &instance_name,
712                false, // is_read_only
713                Some(CONNECT_TIMEOUT),
714            )?
715        } else {
716            let token_provider = PrefetchingTokenProvider::gcp_auth(TOKEN_SCOPES).await?;
717            BigTableConnection::new_with_managed_transport(
718                &project_id,
719                &instance_name,
720                false, // is_read_only
721                Some(CONNECT_TIMEOUT),
722                Arc::new(token_provider),
723                connections.unwrap_or(1),
724                true, // prime_channels
725                None, // app_profile_id
726                MAX_CHANNEL_AGE,
727            )
728            .await?
729        };
730
731        let client = bigtable.client();
732
733        Ok(Self {
734            bigtable,
735            instance_path: format!("projects/{project_id}/instances/{instance_name}"),
736            table_path: client.get_full_table_name(&table_name),
737            table_name,
738        })
739    }
740
741    /// Reads a single row by key, returning parsed row data.
742    ///
743    /// Returns `None` if the row is absent or has expired.
744    #[tracing::instrument(level = "debug", fields(action), skip_all)]
745    async fn read_row(
746        &self,
747        path: &[u8],
748        filter: Option<v2::RowFilter>,
749        action: &'static str,
750    ) -> Result<Option<RowData>> {
751        let request = v2::ReadRowsRequest {
752            table_name: self.table_path.clone(),
753            rows: Some(v2::RowSet {
754                row_keys: vec![path.to_owned()],
755                row_ranges: vec![],
756            }),
757            filter,
758            rows_limit: 1,
759            ..Default::default()
760        };
761
762        let response = retry(action, || async {
763            self.bigtable.client().read_rows(request.clone()).await
764        })
765        .await?;
766        debug_assert!(response.len() <= 1, "Expected at most one row");
767
768        let Some((_, cells)) = response.into_iter().next() else {
769            objectstore_log::debug!("Object not found");
770            return Ok(None);
771        };
772
773        let row = RowData::from_cells(cells)?;
774        Ok(if row.expires_before(SystemTime::now()) {
775            None
776        } else {
777            Some(row)
778        })
779    }
780
781    #[tracing::instrument(level = "debug", fields(action), skip_all)]
782    async fn mutate(
783        &self,
784        path: Vec<u8>,
785        mutations: impl Into<Vec<v2::Mutation>>,
786        action: &'static str,
787    ) -> Result<v2::MutateRowResponse> {
788        let request = v2::MutateRowRequest {
789            table_name: self.table_path.clone(),
790            row_key: path,
791            mutations: mutations.into(),
792            ..Default::default()
793        };
794
795        let response = retry(action, || async {
796            self.bigtable.client().mutate_row(request.clone()).await
797        })
798        .await?;
799
800        Ok(response.into_inner())
801    }
802
803    async fn put_row(
804        &self,
805        path: Vec<u8>,
806        metadata: Metadata,
807        payload: Vec<u8>,
808        action: &'static str,
809    ) -> Result<v2::MutateRowResponse> {
810        let mutations = object_mutations(metadata, payload)?;
811        self.mutate(path, mutations, action).await
812    }
813
814    async fn put_tombstone_row(
815        &self,
816        path: Vec<u8>,
817        tombstone: &Tombstone,
818        action: &'static str,
819    ) -> Result<v2::MutateRowResponse> {
820        let mutations = tombstone_mutations(tombstone, SystemTime::now())?;
821        self.mutate(path, mutations, action).await
822    }
823
824    /// Best-effort TTI bump for a row.
825    ///
826    /// If the payload isn't loaded, it will be fetched. Failures are ignored silently.
827    #[tracing::instrument(level = "debug", fields(?hv_id, loaded), skip_all)]
828    async fn bump_tti(&self, path: Vec<u8>, row: &RowData, loaded: bool, hv_id: &ObjectId) {
829        let expiration_policy = row.expiration_policy();
830
831        match row {
832            RowData::Tombstone { target, .. } => {
833                let target = match parse_redirect_target(target, hv_id) {
834                    Ok(target) => target,
835                    Err(e) => {
836                        objectstore_log::error!(!!&e, "invalid redirect target in tombstone row");
837                        return;
838                    }
839                };
840
841                let tombstone = Tombstone {
842                    target,
843                    expiration_policy,
844                };
845                let _ = self.put_tombstone_row(path, &tombstone, "tti-bump").await;
846            }
847            RowData::Object { metadata, payload } if loaded => {
848                let bumped = bumped_tti_metadata(metadata);
849                let _ = self
850                    .put_row(path, bumped, payload.clone(), "tti-bump")
851                    .await;
852            }
853            RowData::Object { metadata, .. } => {
854                let payload_read = self
855                    .read_row(&path, Some(column_filter(COLUMN_PAYLOAD)), "tti-bump")
856                    .await;
857
858                if let Ok(Some(RowData::Object { payload, .. })) = payload_read {
859                    let bumped = bumped_tti_metadata(metadata);
860                    let _ = self.put_row(path, bumped, payload, "tti-bump").await;
861                }
862            }
863        }
864    }
865
866    /// Executes a `CheckAndMutateRow` request.
867    #[tracing::instrument(level = "debug", fields(action = context), skip_all)]
868    async fn check_and_mutate(
869        &self,
870        row_key: Vec<u8>,
871        predicate: MutatePredicate,
872        mutations: impl Into<Vec<v2::Mutation>>,
873        context: &'static str,
874    ) -> Result<bool> {
875        let (filter, true_mutations, false_mutations, success_on_match) = match predicate {
876            MutatePredicate::Include(f) => (f, mutations.into(), vec![], true),
877            MutatePredicate::Exclude(f) => (f, vec![], mutations.into(), false),
878        };
879
880        let request = v2::CheckAndMutateRowRequest {
881            table_name: self.table_path.clone(),
882            row_key,
883            predicate_filter: Some(filter),
884            true_mutations,
885            false_mutations,
886            ..Default::default()
887        };
888
889        let future = retry(context, || async {
890            self.bigtable
891                .client()
892                .check_and_mutate_row(request.clone())
893                .await
894        });
895
896        Ok(future.await?.predicate_matched == success_on_match)
897    }
898}
899
900#[async_trait::async_trait]
901impl Backend for BigTableBackend {
902    fn name(&self) -> &'static str {
903        "bigtable"
904    }
905
906    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
907    async fn put_object(
908        &self,
909        id: &ObjectId,
910        metadata: &Metadata,
911        mut stream: ClientStream,
912    ) -> Result<PutResponse> {
913        objectstore_log::debug!("Writing to Bigtable backend");
914        let path = id.as_storage_path().to_string().into_bytes();
915
916        let mut payload = ChunkedBytes::new(0);
917        while let Some(chunk) = stream.try_next().await? {
918            payload.push(chunk);
919        }
920
921        self.put_row(path, metadata.clone(), payload.into_bytes().into(), "put")
922            .await?;
923
924        Ok(())
925    }
926
927    #[tracing::instrument(level = "debug", skip(self))]
928    async fn get_object(&self, id: &ObjectId, range: Option<ByteRange>) -> Result<GetResponse> {
929        match self.get_tiered_object(id, range).await? {
930            TieredGet::Object(metadata, content_range, payload) => {
931                Ok(Some((metadata, content_range, payload)))
932            }
933            TieredGet::Tombstone(_) => Err(Error::UnexpectedTombstone),
934            TieredGet::NotFound => Ok(None),
935        }
936    }
937
938    #[tracing::instrument(level = "debug", skip(self))]
939    async fn get_metadata(&self, id: &ObjectId) -> Result<MetadataResponse> {
940        match self.get_tiered_metadata(id).await? {
941            TieredMetadata::Object(metadata) => Ok(Some(metadata)),
942            TieredMetadata::Tombstone(_) => Err(Error::UnexpectedTombstone),
943            TieredMetadata::NotFound => Ok(None),
944        }
945    }
946
947    #[tracing::instrument(level = "debug", skip(self))]
948    async fn delete_object(&self, id: &ObjectId) -> Result<DeleteResponse> {
949        objectstore_log::debug!("Deleting from Bigtable backend");
950
951        let path = id.as_storage_path().to_string().into_bytes();
952        self.mutate(path, [delete_row_mutation()], "delete").await?;
953
954        Ok(())
955    }
956}
957
958#[async_trait::async_trait]
959impl HighVolumeBackend for BigTableBackend {
960    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
961    async fn put_non_tombstone(
962        &self,
963        id: &ObjectId,
964        metadata: &Metadata,
965        payload: Bytes,
966    ) -> Result<Option<Tombstone>> {
967        objectstore_log::debug!("Conditional put to Bigtable backend");
968
969        let path = id.as_storage_path().to_string().into_bytes();
970        let mutations = object_mutations(metadata.clone(), payload.to_vec())?;
971
972        for _ in 0..CAS_RETRY_COUNT {
973            let write_succeeded = self
974                .check_and_mutate(
975                    path.clone(),
976                    tombstone_predicate(),
977                    mutations.clone(),
978                    "put_non_tombstone",
979                )
980                .await?;
981
982            if write_succeeded {
983                return Ok(None);
984            }
985
986            // A tombstone was present: read its data for the caller.
987            let row = self
988                .read_row(&path, Some(metadata_filter()), "put_non_tombstone")
989                .await?;
990
991            match row {
992                Some(RowData::Tombstone { target, meta, .. }) => {
993                    return Ok(Some(Tombstone {
994                        target: parse_redirect_target(&target, id)?,
995                        expiration_policy: meta.expiration_policy,
996                    }));
997                }
998                // Race: Tombstone was replaced by an object, retry to overwrite
999                Some(RowData::Object { .. }) => continue,
1000                // Race: Tombstone was deleted, retry to write.
1001                None => continue,
1002            }
1003        }
1004
1005        Err(Error::generic("BigTable: race loop in put_non_tombstone"))
1006    }
1007
1008    #[tracing::instrument(level = "debug", skip(self))]
1009    async fn get_tiered_object(
1010        &self,
1011        id: &ObjectId,
1012        range: Option<ByteRange>,
1013    ) -> Result<TieredGet> {
1014        objectstore_log::debug!("Reading from Bigtable backend");
1015        let path = id.as_storage_path().to_string().into_bytes();
1016
1017        let Some(row) = self.read_row(&path, None, "get_tiered_object").await? else {
1018            return Ok(TieredGet::NotFound);
1019        };
1020
1021        // TODO: extract into dedicated call from service
1022        if row.needs_tti_bump() {
1023            self.bump_tti(path.clone(), &row, true, id).await;
1024        }
1025
1026        Ok(match row {
1027            RowData::Tombstone { meta, target, .. } => TieredGet::Tombstone(Tombstone {
1028                target: parse_redirect_target(&target, id)?,
1029                expiration_policy: meta.expiration_policy,
1030            }),
1031            RowData::Object { metadata, payload } => {
1032                let mut metadata = metadata;
1033                let payload = Bytes::from(payload);
1034                if metadata.size.is_none() {
1035                    // If object size wasn't written into the metadata, re-compute it now
1036                    metadata.size = Some(payload.len());
1037                }
1038
1039                let (content_range, payload) = apply_range(payload, range)?;
1040                TieredGet::Object(metadata, content_range, crate::stream::single(payload))
1041            }
1042        })
1043    }
1044
1045    #[tracing::instrument(level = "debug", skip(self))]
1046    async fn get_tiered_metadata(&self, id: &ObjectId) -> Result<TieredMetadata> {
1047        objectstore_log::debug!("Reading metadata from Bigtable backend");
1048        let path = id.as_storage_path().to_string().into_bytes();
1049
1050        // Read metadata and tombstone columns — skip the (potentially large) payload.
1051        // NB: `metadata.size` will only be populated if the size was added to the metadata before
1052        // writing to Bigtable.
1053        let row_opt = self
1054            .read_row(&path, Some(metadata_filter()), "get_tiered_metadata")
1055            .await?;
1056        let Some(row) = row_opt else {
1057            return Ok(TieredMetadata::NotFound);
1058        };
1059
1060        // TODO: extract into dedicated call from service
1061        if row.needs_tti_bump() {
1062            self.bump_tti(path.clone(), &row, false, id).await;
1063        }
1064
1065        Ok(match row {
1066            RowData::Tombstone { meta, target, .. } => TieredMetadata::Tombstone(Tombstone {
1067                target: parse_redirect_target(&target, id)?,
1068                expiration_policy: meta.expiration_policy,
1069            }),
1070            RowData::Object { metadata, .. } => TieredMetadata::Object(metadata),
1071        })
1072    }
1073
1074    #[tracing::instrument(level = "debug", skip(self))]
1075    async fn delete_non_tombstone(&self, id: &ObjectId) -> Result<Option<Tombstone>> {
1076        objectstore_log::debug!("Conditional delete from Bigtable backend");
1077
1078        let path = id.as_storage_path().to_string().into_bytes();
1079
1080        for _ in 0..CAS_RETRY_COUNT {
1081            let write_succeeded = self
1082                .check_and_mutate(
1083                    path.clone(),
1084                    tombstone_predicate(),
1085                    [delete_row_mutation()],
1086                    "delete_non_tombstone",
1087                )
1088                .await?;
1089
1090            if write_succeeded {
1091                return Ok(None);
1092            }
1093
1094            // A tombstone was present: read its data for the caller.
1095            let row = self
1096                .read_row(&path, Some(metadata_filter()), "delete_non_tombstone")
1097                .await?;
1098
1099            match row {
1100                Some(RowData::Tombstone { target, meta, .. }) => {
1101                    return Ok(Some(Tombstone {
1102                        target: parse_redirect_target(&target, id)?,
1103                        expiration_policy: meta.expiration_policy,
1104                    }));
1105                }
1106                // Race: An object replaced the tombstone, delete the new object now.
1107                Some(RowData::Object { .. }) => continue,
1108                // Race: Entry was deleted in the meanwhile, nothing left to do.
1109                None => return Ok(None),
1110            }
1111        }
1112
1113        Err(Error::generic(
1114            "BigTable: race loop in delete_non_tombstone",
1115        ))
1116    }
1117
1118    #[tracing::instrument(level = "debug", skip(self, write))]
1119    async fn compare_and_write(
1120        &self,
1121        id: &ObjectId,
1122        current: Option<&ObjectId>,
1123        write: TieredWrite,
1124    ) -> Result<bool> {
1125        objectstore_log::debug!("CAS put to Bigtable backend");
1126
1127        let path = id.as_storage_path().to_string().into_bytes();
1128        let now = SystemTime::now();
1129
1130        let predicate = match (current, write.target()) {
1131            (Some(old), Some(new)) => update_predicate(old, new, id),
1132            (Some(target), None) => optional_target_predicate(target, id),
1133            (None, Some(target)) => optional_target_predicate(target, id),
1134            (None, None) => tombstone_predicate(),
1135        };
1136
1137        let mutations = match write {
1138            TieredWrite::Tombstone(tombstone) => tombstone_mutations(&tombstone, now)?.into(),
1139            TieredWrite::Object(m, p) => object_mutations(m, p.to_vec())?.into(),
1140            TieredWrite::Delete => vec![delete_row_mutation()],
1141        };
1142
1143        self.check_and_mutate(path, predicate, mutations, "compare_and_write")
1144            .await
1145    }
1146}
1147
1148/// Converts the given TTL duration to a microsecond-precision unix timestamp.
1149///
1150/// The TTL is anchored at the provided `from` timestamp, which defaults to `SystemTime::now()`. As
1151/// required by BigTable, the resulting timestamp has millisecond precision, with the last digits at
1152/// 0.
1153fn ttl_to_micros(ttl: Duration, from: SystemTime) -> Result<i64> {
1154    let deadline = from.checked_add(ttl).ok_or_else(|| Error::Generic {
1155        context: format!(
1156            "TTL duration overflow: {} plus {}s cannot be represented as SystemTime",
1157            humantime::format_rfc3339_seconds(from),
1158            ttl.as_secs()
1159        ),
1160        cause: None,
1161    })?;
1162
1163    system_time_to_micros(deadline)
1164}
1165
1166/// Converts a [`SystemTime`] to a microsecond-precision unix timestamp.
1167///
1168/// As required by BigTable, the resulting timestamp has millisecond precision, with the last digits
1169/// at 0.
1170fn system_time_to_micros(deadline: SystemTime) -> Result<i64> {
1171    let millis = deadline
1172        .duration_since(SystemTime::UNIX_EPOCH)
1173        .map_err(|e| Error::Generic {
1174            context: format!(
1175                "unable to get duration since UNIX_EPOCH for SystemTime {}",
1176                humantime::format_rfc3339_seconds(deadline)
1177            ),
1178            cause: Some(Box::new(e)),
1179        })?
1180        .as_millis();
1181
1182    (millis * 1000).try_into().map_err(|e| Error::Generic {
1183        context: format!("failed to convert {millis}ms to i64 microseconds"),
1184        cause: Some(Box::new(e)),
1185    })
1186}
1187
1188/// Converts a wall-clock time to Bigtable's microsecond timestamp, saturating at `i64::MAX`
1189/// (unreachable until approximately year 294,247).
1190fn time_to_micros_saturating(t: SystemTime) -> i64 {
1191    let millis = t
1192        .duration_since(SystemTime::UNIX_EPOCH)
1193        .unwrap_or_default()
1194        .as_millis();
1195    i64::try_from(millis * 1000).unwrap_or(i64::MAX)
1196}
1197
1198/// Converts a microsecond-precision unix timestamp to a `SystemTime`.
1199fn micros_to_time(micros: i64) -> Option<SystemTime> {
1200    let micros = u64::try_from(micros).ok()?;
1201    let duration = Duration::from_micros(micros);
1202    SystemTime::UNIX_EPOCH.checked_add(duration)
1203}
1204
1205/// Retries a BigTable RPC on transient errors.
1206async fn retry<T, F>(context: &'static str, f: impl Fn() -> F) -> Result<T>
1207where
1208    F: Future<Output = Result<T, BigTableError>> + Send,
1209{
1210    let mut retry_count = 0usize;
1211
1212    loop {
1213        let attempt_span = tracing::debug_span!(
1214            "bigtable.request",
1215            action = context,
1216            grpc.status = tracing::field::Empty,
1217        );
1218        let attempt = async {
1219            let result = f().await;
1220            let span = tracing::Span::current();
1221            match &result {
1222                Ok(_) => span.record("grpc.status", "ok"),
1223                Err(BigTableError::RpcError(status)) => {
1224                    span.record("grpc.status", tracing::field::debug(status.code()))
1225                }
1226                // Non-RPC error; the error event carries the details.
1227                Err(_) => &span,
1228            };
1229            result
1230        };
1231
1232        match attempt.instrument(attempt_span).await {
1233            Ok(res) => return Ok(res),
1234            Err(e) if retry_count >= REQUEST_RETRY_COUNT || !is_retryable(&e) => {
1235                objectstore_metrics::count!("bigtable.failures", action = context);
1236                return Err(Error::Generic {
1237                    context: format!("Bigtable: `{context}` failed"),
1238                    cause: Some(Box::new(e)),
1239                });
1240            }
1241            Err(e) => {
1242                retry_count += 1;
1243                objectstore_metrics::count!("bigtable.retries", action = context);
1244                objectstore_log::warn!(!!&e, retry_count, context, "Retrying request");
1245            }
1246        }
1247    }
1248}
1249
1250fn is_retryable(error: &BigTableError) -> bool {
1251    match error {
1252        // Transient errors on auth token refresh
1253        BigTableError::GCPAuthError(_) => true,
1254        // Transient GRPC network failures
1255        BigTableError::TransportError(_) => true,
1256        // These could also indicate transient network failures
1257        BigTableError::IoError(_) => true,
1258        BigTableError::TimeoutError(_) => true,
1259
1260        // See https://docs.cloud.google.com/bigtable/docs/status-codes
1261        BigTableError::RpcError(status) => match status.code() {
1262            // Generic retriable status
1263            Code::Unavailable => true,
1264            // Timeouts
1265            Code::Cancelled => true,
1266            Code::DeadlineExceeded => true,
1267            // Token might have refreshed too late
1268            Code::Unauthenticated => true,
1269            // Unspecified, attempt to retry anyways
1270            Code::Aborted => true,
1271            Code::Internal => true,
1272            Code::FailedPrecondition => true,
1273            Code::Unknown => true,
1274            _ => false,
1275        },
1276        _ => false,
1277    }
1278}
1279
1280/// Resolves an optional byte range against a payload buffer, returning the
1281/// applicable content range and the (potentially narrowed) payload.
1282///
1283/// When `range` is `None`, returns the full payload unchanged. Uses
1284/// `Bytes::slice` to avoid copying data.
1285fn apply_range(payload: Bytes, range: Option<ByteRange>) -> Result<(Option<ContentRange>, Bytes)> {
1286    let Some(byte_range) = range else {
1287        return Ok((None, payload));
1288    };
1289
1290    let total = payload.len() as u64;
1291    let content_range = byte_range
1292        .resolve(total)
1293        .ok_or(Error::RangeNotSatisfiable { total })?;
1294
1295    let sliced = payload.slice(content_range.start as usize..content_range.end as usize + 1);
1296    Ok((Some(content_range), sliced))
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301    use std::collections::BTreeMap;
1302
1303    use anyhow::Result;
1304    use objectstore_types::scope::{Scope, Scopes};
1305
1306    use super::*;
1307    use crate::id::ObjectContext;
1308    use crate::stream;
1309
1310    // NB: Most of these tests require a BigTable emulator running. This is done
1311    // automatically in CI.
1312    //
1313    // Refer to the readme for how to set up the emulator.
1314
1315    async fn create_test_backend() -> Result<BigTableBackend> {
1316        BigTableBackend::new(BigTableConfig {
1317            endpoint: Some("localhost:8086".into()),
1318            project_id: "testing".into(),
1319            instance_name: "objectstore".into(),
1320            table_name: "objectstore".into(),
1321            connections: None,
1322        })
1323        .await
1324    }
1325
1326    fn make_id() -> ObjectId {
1327        ObjectId::random(ObjectContext {
1328            usecase: "testing".into(),
1329            scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1330        })
1331    }
1332
1333    async fn create_object(
1334        backend: &BigTableBackend,
1335        id: &ObjectId,
1336        metadata: &Metadata,
1337        payload: &[u8],
1338        now: SystemTime,
1339    ) -> Result<()> {
1340        let path = id.as_storage_path().to_string().into_bytes();
1341        // Resolve `time_expires` from `now` (as `from_insert_headers` does) unless the test set
1342        // it explicitly, so `object_mutations` has an expiration to persist.
1343        let mut metadata = metadata.clone();
1344        if metadata.time_expires.is_none() {
1345            metadata.time_expires = metadata.expiration_policy.expires_in().map(|ttl| now + ttl);
1346        }
1347        let mutations = object_mutations(metadata, payload.to_vec())?;
1348        backend.mutate(path, mutations, "test-setup").await?;
1349        Ok(())
1350    }
1351
1352    async fn create_tombstone(
1353        backend: &BigTableBackend,
1354        id: &ObjectId,
1355        tombstone: &Tombstone,
1356        now: SystemTime,
1357    ) -> Result<()> {
1358        let path = id.as_storage_path().to_string().into_bytes();
1359        let mutations = tombstone_mutations(tombstone, now)?;
1360        backend.mutate(path, mutations, "test-setup").await?;
1361        Ok(())
1362    }
1363
1364    /// Writes a legacy-format tombstone row directly into Bigtable.
1365    async fn write_legacy_tombstone(
1366        backend: &BigTableBackend,
1367        id: &ObjectId,
1368        expiration_policy: ExpirationPolicy,
1369        time_expires: Option<SystemTime>,
1370    ) -> Result<()> {
1371        let meta = if expiration_policy.is_manual() {
1372            r#"{"is_redirect_tombstone":true}"#.to_owned()
1373        } else {
1374            let policy_json = serde_json::to_string(&expiration_policy).unwrap();
1375            format!(r#"{{"is_redirect_tombstone":true,"expiration_policy":{policy_json}}}"#)
1376        };
1377
1378        let (family, timestamp_micros) = if expiration_policy.is_manual() {
1379            (FAMILY_MANUAL, -1)
1380        } else {
1381            let t =
1382                time_expires.unwrap_or(SystemTime::now() + expiration_policy.expires_in().unwrap());
1383            (FAMILY_GC, time_to_micros_saturating(t))
1384        };
1385
1386        let path = id.as_storage_path().to_string().into_bytes();
1387        let mutations = [mutation(mutation::Mutation::SetCell(mutation::SetCell {
1388            family_name: family.to_owned(),
1389            column_qualifier: COLUMN_METADATA.to_owned(),
1390            timestamp_micros,
1391            value: meta.into_bytes(),
1392        }))];
1393
1394        backend.mutate(path, mutations, "test-setup").await?;
1395
1396        Ok(())
1397    }
1398
1399    /// Writes a new-format tombstone row with an empty `r` value directly,
1400    /// simulating rows written by code before this change.
1401    async fn write_empty_redirect_tombstone(
1402        backend: &BigTableBackend,
1403        id: &ObjectId,
1404    ) -> Result<()> {
1405        let path = id.as_storage_path().to_string().into_bytes();
1406        let mutations = [
1407            mutation(mutation::Mutation::SetCell(mutation::SetCell {
1408                family_name: FAMILY_MANUAL.to_owned(),
1409                column_qualifier: COLUMN_REDIRECT.to_owned(),
1410                timestamp_micros: -1,
1411                value: b"".to_vec(), // empty — legacy format
1412            })),
1413            mutation(mutation::Mutation::SetCell(mutation::SetCell {
1414                family_name: FAMILY_MANUAL.to_owned(),
1415                column_qualifier: COLUMN_TOMBSTONE_META.to_owned(),
1416                timestamp_micros: -1,
1417                value: b"{}".to_vec(),
1418            })),
1419        ];
1420
1421        backend.mutate(path, mutations, "test-setup").await?;
1422
1423        Ok(())
1424    }
1425
1426    // --- Section 1: Object Operations ---
1427
1428    /// Verifies the full roundtrip: put → get_object (payload + metadata) → get_metadata (metadata).
1429    #[tokio::test]
1430    async fn test_roundtrip() -> Result<()> {
1431        let backend = create_test_backend().await?;
1432
1433        let id = make_id();
1434        let metadata = Metadata {
1435            content_type: "text/plain".into(),
1436            time_created: Some(SystemTime::now()),
1437            custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1438            ..Default::default()
1439        };
1440
1441        backend
1442            .put_object(&id, &metadata, stream::single("hello, world"))
1443            .await?;
1444
1445        let (obj_meta, _, stream) = backend.get_object(&id, None).await?.unwrap();
1446        let payload = stream::read_to_vec(stream).await?;
1447        assert_eq!(payload, b"hello, world");
1448        assert_eq!(obj_meta.content_type, metadata.content_type);
1449        assert_eq!(obj_meta.custom, metadata.custom);
1450
1451        let head_meta = backend.get_metadata(&id).await?.unwrap();
1452        assert_eq!(head_meta.content_type, metadata.content_type);
1453        assert_eq!(head_meta.custom, metadata.custom);
1454
1455        Ok(())
1456    }
1457
1458    /// Verifies that a server-resolved `time_expires` is persisted verbatim, not recomputed.
1459    #[tokio::test]
1460    async fn test_time_expires_roundtrip() -> Result<()> {
1461        let backend = create_test_backend().await?;
1462
1463        let id = make_id();
1464        let ttl = Duration::from_hours(2 * 24);
1465        let expires = SystemTime::now() + ttl;
1466        let metadata = Metadata {
1467            expiration_policy: ExpirationPolicy::TimeToLive(ttl),
1468            time_expires: Some(expires),
1469            ..Default::default()
1470        };
1471        create_object(&backend, &id, &metadata, b"data", SystemTime::now()).await?;
1472
1473        let meta = backend.get_metadata(&id).await?.unwrap();
1474        // Bigtable stores the deadline as the GC cell timestamp at millisecond precision.
1475        let stored_ms = meta
1476            .time_expires
1477            .unwrap()
1478            .duration_since(SystemTime::UNIX_EPOCH)?
1479            .as_millis();
1480        let expected_ms = expires.duration_since(SystemTime::UNIX_EPOCH)?.as_millis();
1481        assert_eq!(stored_ms, expected_ms);
1482
1483        Ok(())
1484    }
1485
1486    /// Verifies that absent rows return None or succeed silently for all read/delete operations.
1487    #[tokio::test]
1488    async fn test_nonexistent() -> Result<()> {
1489        let backend = create_test_backend().await?;
1490
1491        let id = make_id();
1492        assert!(backend.get_object(&id, None).await?.is_none());
1493        assert!(backend.get_metadata(&id).await?.is_none());
1494        backend.delete_object(&id).await?;
1495
1496        Ok(())
1497    }
1498
1499    #[tokio::test]
1500    async fn test_overwrite() -> Result<()> {
1501        let backend = create_test_backend().await?;
1502
1503        let id = make_id();
1504        let first_metadata = Metadata {
1505            custom: BTreeMap::from_iter([("invalid".into(), "invalid".into())]),
1506            ..Default::default()
1507        };
1508        create_object(&backend, &id, &first_metadata, b"hello", SystemTime::now()).await?;
1509
1510        let second_metadata = Metadata {
1511            custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1512            ..Default::default()
1513        };
1514        backend
1515            .put_object(&id, &second_metadata, stream::single("world"))
1516            .await?;
1517
1518        let (meta, _, stream) = backend.get_object(&id, None).await?.unwrap();
1519        let payload = stream::read_to_vec(stream).await?;
1520        assert_eq!(payload, b"world");
1521        assert_eq!(meta.custom, second_metadata.custom);
1522
1523        Ok(())
1524    }
1525
1526    #[tokio::test]
1527    async fn test_read_after_delete() -> Result<()> {
1528        let backend = create_test_backend().await?;
1529
1530        let id = make_id();
1531        let metadata = Metadata::default();
1532        create_object(&backend, &id, &metadata, b"hello", SystemTime::now()).await?;
1533        backend.delete_object(&id).await?;
1534
1535        assert!(backend.get_object(&id, None).await?.is_none());
1536
1537        Ok(())
1538    }
1539
1540    /// Verifies TTI bump via both `get_object` (loaded=true path) and `get_metadata` (loaded=false path).
1541    ///
1542    /// The bump condition is: `expire_at < now + tti - TTI_DEBOUNCE`. We write a stale
1543    /// timestamp just inside the bump window (still in the future, so the row is not GC'd)
1544    /// and confirm that a subsequent read returns a later expiry.
1545    #[tokio::test]
1546    async fn test_tti_bump() -> Result<()> {
1547        let backend = create_test_backend().await?;
1548        // TTI must exceed TTI_DEBOUNCE (1 day) for the bump condition to be reachable.
1549        let tti = Duration::from_hours(2 * 24);
1550        let metadata = Metadata {
1551            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1552            ..Default::default()
1553        };
1554
1555        // Pass a backdated `now` so the written expiry is inside the bump window:
1556        // expire_at = past_now + tti = now - TTI_DEBOUNCE - 60s (stale but not yet expired).
1557        let past_now = SystemTime::now() - TTI_DEBOUNCE - Duration::from_mins(1);
1558
1559        // Sub-sequence 1: get_object triggers bump (loaded=true path).
1560        let id1 = make_id();
1561        create_object(&backend, &id1, &metadata, b"hello, world", past_now).await?;
1562
1563        // get_object reads the stale row, triggers bump, and returns the pre-bump metadata.
1564        let (pre_obj_meta, _, _) = backend.get_object(&id1, None).await?.unwrap();
1565        let pre_obj_expiry = pre_obj_meta.time_expires.unwrap();
1566
1567        // A second get_metadata reads the freshly bumped row.
1568        let post_obj_meta = backend.get_metadata(&id1).await?.unwrap();
1569        let post_obj_expiry = post_obj_meta.time_expires.unwrap();
1570        assert!(
1571            post_obj_expiry > pre_obj_expiry,
1572            "bump should extend expiry"
1573        );
1574
1575        // Sub-sequence 2: get_metadata triggers bump (loaded=false path).
1576        let id2 = make_id();
1577        create_object(&backend, &id2, &metadata, b"hello, world", past_now).await?;
1578
1579        // First get_metadata sees the stale row and triggers a bump.
1580        let pre_meta = backend.get_metadata(&id2).await?.unwrap();
1581        let pre_expiry = pre_meta.time_expires.unwrap();
1582
1583        // Second get_metadata reads the freshly bumped row.
1584        let post_meta = backend.get_metadata(&id2).await?.unwrap();
1585        let post_expiry = post_meta.time_expires.unwrap();
1586        assert!(post_expiry > pre_expiry, "bump should extend expiry");
1587
1588        // Payload must be intact after the loaded=false bump (which re-fetches the payload).
1589        let (_, _, stream) = backend.get_object(&id2, None).await?.unwrap();
1590        let payload = stream::read_to_vec(stream).await?;
1591        assert_eq!(payload, b"hello, world");
1592
1593        Ok(())
1594    }
1595
1596    #[tokio::test]
1597    async fn test_tti_no_bump_when_fresh() -> Result<()> {
1598        let backend = create_test_backend().await?;
1599
1600        let id = make_id();
1601        // TTI must exceed TTI_DEBOUNCE (1 day) for the bump condition to be reachable.
1602        let tti = Duration::from_hours(2 * 24);
1603        let metadata = Metadata {
1604            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1605            ..Default::default()
1606        };
1607        create_object(&backend, &id, &metadata, b"hello, world", SystemTime::now()).await?;
1608
1609        // A freshly written object has time_expires ≈ now + 2d, well outside the bump
1610        // window (now + 2d - 1d = now + 1d). No bump should occur.
1611        let first = backend.get_metadata(&id).await?.unwrap();
1612        let second = backend.get_metadata(&id).await?.unwrap();
1613
1614        assert_eq!(
1615            first.time_expires.unwrap(),
1616            second.time_expires.unwrap(),
1617            "fresh TTI object must not be bumped"
1618        );
1619
1620        Ok(())
1621    }
1622
1623    // --- Section 2: Expiration ---
1624
1625    #[tokio::test]
1626    async fn test_ttl_immediate() -> Result<()> {
1627        // NB: We create a TTL that immediately expires in this test. This might be optimized away
1628        // in a future implementation, so we will have to update this test accordingly.
1629
1630        let backend = create_test_backend().await?;
1631
1632        let id = make_id();
1633        let metadata = Metadata {
1634            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
1635            ..Default::default()
1636        };
1637        create_object(&backend, &id, &metadata, b"hello, world", SystemTime::now()).await?;
1638
1639        assert!(backend.get_object(&id, None).await?.is_none());
1640
1641        Ok(())
1642    }
1643
1644    #[tokio::test]
1645    async fn test_tti_immediate() -> Result<()> {
1646        // NB: We create a TTI that immediately expires in this test. This might be optimized away
1647        // in a future implementation, so we will have to update this test accordingly.
1648
1649        let backend = create_test_backend().await?;
1650
1651        let id = make_id();
1652        let metadata = Metadata {
1653            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
1654            ..Default::default()
1655        };
1656        create_object(&backend, &id, &metadata, b"hello, world", SystemTime::now()).await?;
1657
1658        assert!(backend.get_object(&id, None).await?.is_none());
1659
1660        Ok(())
1661    }
1662
1663    // --- Section 3: Tiered Operations ---
1664
1665    /// Covers all three row states for `get_tiered_object` and `get_tiered_metadata`.
1666    ///
1667    /// - **empty**: both return NotFound.
1668    /// - **object**: put_object, both return the Object variant with correct payload/metadata.
1669    /// - **tombstone**: CAS-write with a distinct `lt_id`, both return the Tombstone variant
1670    ///   with `target == lt_id`.
1671    #[tokio::test]
1672    async fn test_tiered_get() -> Result<()> {
1673        let backend = create_test_backend().await?;
1674
1675        // empty
1676        let id = make_id();
1677        assert!(matches!(
1678            backend.get_tiered_object(&id, None).await?,
1679            TieredGet::NotFound
1680        ));
1681        assert!(matches!(
1682            backend.get_tiered_metadata(&id).await?,
1683            TieredMetadata::NotFound
1684        ));
1685
1686        // object
1687        let id = make_id();
1688        let put_meta = Metadata {
1689            content_type: "text/plain".into(),
1690            custom: BTreeMap::from_iter([("k".into(), "v".into())]),
1691            ..Default::default()
1692        };
1693        create_object(&backend, &id, &put_meta, b"payload", SystemTime::now()).await?;
1694
1695        let TieredGet::Object(obj_meta, _, obj_stream) =
1696            backend.get_tiered_object(&id, None).await?
1697        else {
1698            panic!("expected TieredGet::Object");
1699        };
1700        let obj_payload = stream::read_to_vec(obj_stream).await?;
1701        assert_eq!(obj_payload, b"payload");
1702        assert_eq!(obj_meta.content_type, put_meta.content_type);
1703        assert_eq!(obj_meta.custom, put_meta.custom);
1704
1705        let TieredMetadata::Object(head_meta) = backend.get_tiered_metadata(&id).await? else {
1706            panic!("expected TieredMetadata::Object");
1707        };
1708        assert_eq!(head_meta.content_type, put_meta.content_type);
1709        assert_eq!(head_meta.custom, put_meta.custom);
1710
1711        // tombstone
1712        let hv_id = make_id();
1713        let lt_id = ObjectId::random(hv_id.context().clone());
1714        let tombstone = Tombstone {
1715            target: lt_id.clone(),
1716            expiration_policy: ExpirationPolicy::Manual,
1717        };
1718        create_tombstone(&backend, &hv_id, &tombstone, SystemTime::now()).await?;
1719
1720        match backend.get_tiered_object(&hv_id, None).await? {
1721            TieredGet::Tombstone(get_t) => assert_eq!(get_t.target, lt_id),
1722            other => panic!("expected TieredGet::Tombstone, got {other:?}"),
1723        }
1724        match backend.get_tiered_metadata(&hv_id).await? {
1725            TieredMetadata::Tombstone(meta_t) => assert_eq!(meta_t.target, lt_id,),
1726            other => panic!("expected TieredMetadata::Tombstone, got {other:?}"),
1727        }
1728
1729        Ok(())
1730    }
1731
1732    /// Covers all three row states for `put_non_tombstone`.
1733    ///
1734    /// - **empty**: returns None, object is readable.
1735    /// - **object**: overwrites with new payload, returns None.
1736    /// - **tombstone**: returns Some(Tombstone) with the correct target; tombstone still intact.
1737    #[tokio::test]
1738    async fn test_put_non_tombstone() -> Result<()> {
1739        let backend = create_test_backend().await?;
1740
1741        // empty: put_non_tombstone on absent row succeeds and makes object readable.
1742        let id = make_id();
1743        let metadata = Metadata::default();
1744        let result = backend
1745            .put_non_tombstone(&id, &metadata, Bytes::from_static(b"first"))
1746            .await?;
1747        assert_eq!(result, None, "expected None on empty row");
1748        let (_, _, stream) = backend.get_object(&id, None).await?.unwrap();
1749        assert_eq!(&stream::read_to_vec(stream).await?, b"first");
1750
1751        // object: put_non_tombstone on existing object replaces payload, returns None.
1752        let id = make_id();
1753        create_object(&backend, &id, &metadata, b"old", SystemTime::now()).await?;
1754        let result = backend
1755            .put_non_tombstone(&id, &metadata, Bytes::from_static(b"new"))
1756            .await?;
1757        assert_eq!(result, None, "expected None when overwriting object");
1758        let (_, _, stream) = backend.get_object(&id, None).await?.unwrap();
1759        assert_eq!(&stream::read_to_vec(stream).await?, b"new");
1760
1761        // tombstone: put_non_tombstone returns Some(Tombstone) and leaves tombstone intact.
1762        let hv_id = make_id();
1763        let lt_id = ObjectId::random(hv_id.context().clone());
1764        let tombstone = Tombstone {
1765            target: lt_id.clone(),
1766            expiration_policy: ExpirationPolicy::Manual,
1767        };
1768        create_tombstone(&backend, &hv_id, &tombstone, SystemTime::now()).await?;
1769        let result = backend
1770            .put_non_tombstone(&hv_id, &metadata, Bytes::new())
1771            .await?;
1772        let returned = result.expect("expected Some(Tombstone) when row is a tombstone");
1773        assert_eq!(returned.target, lt_id);
1774        assert!(
1775            matches!(
1776                backend.get_tiered_metadata(&hv_id).await?,
1777                TieredMetadata::Tombstone(_)
1778            ),
1779            "tombstone must still exist after put_non_tombstone"
1780        );
1781
1782        Ok(())
1783    }
1784
1785    /// Covers all three row states for `delete_non_tombstone`.
1786    ///
1787    /// - **empty**: returns None.
1788    /// - **object**: returns None, row gone.
1789    /// - **tombstone**: returns Some(Tombstone) with correct target; tombstone still intact.
1790    ///
1791    /// Verifies that the `r` column is correctly detected by both the `ReadRows` column
1792    /// filter and the `CheckAndMutate` `tombstone_predicate`.
1793    #[tokio::test]
1794    async fn test_delete_non_tombstone() -> Result<()> {
1795        let backend = create_test_backend().await?;
1796
1797        // empty
1798        let id = make_id();
1799        assert_eq!(backend.delete_non_tombstone(&id).await?, None);
1800
1801        // object
1802        let id = make_id();
1803        let metadata = Metadata::default();
1804        create_object(&backend, &id, &metadata, b"hello, world", SystemTime::now()).await?;
1805        assert_eq!(backend.delete_non_tombstone(&id).await?, None);
1806        assert!(backend.get_object(&id, None).await?.is_none());
1807
1808        // tombstone
1809        let id = make_id();
1810        let tombstone = Tombstone {
1811            target: id.clone(),
1812            expiration_policy: ExpirationPolicy::Manual,
1813        };
1814        create_tombstone(&backend, &id, &tombstone, SystemTime::now()).await?;
1815        let tombstone = backend
1816            .delete_non_tombstone(&id)
1817            .await?
1818            .expect("expected Some(tombstone)");
1819        assert_eq!(tombstone.target, id, "tombstone target must be returned");
1820        assert!(
1821            matches!(
1822                backend.get_tiered_metadata(&id).await?,
1823                TieredMetadata::Tombstone(_)
1824            ),
1825            "tombstone must still exist after delete_non_tombstone"
1826        );
1827
1828        Ok(())
1829    }
1830
1831    // --- Section 4: Compare-and-Write ---
1832
1833    /// Creating a tombstone on an empty row succeeds; a retry of the same CAS also succeeds.
1834    ///
1835    /// After creation, both tiered and legacy APIs reflect the tombstone.
1836    #[tokio::test]
1837    async fn test_cas_create_tombstone() -> Result<()> {
1838        let backend = create_test_backend().await?;
1839
1840        let hv_id = make_id();
1841        let lt_id = ObjectId::random(hv_id.context().clone());
1842        let expiration_policy = ExpirationPolicy::TimeToLive(Duration::from_hours(1));
1843        let tombstone = Tombstone {
1844            target: lt_id.clone(),
1845            expiration_policy,
1846        };
1847
1848        // First create succeeds.
1849        let committed = backend
1850            .compare_and_write(&hv_id, None, TieredWrite::Tombstone(tombstone.clone()))
1851            .await?;
1852        assert!(committed, "expected CAS success on empty row");
1853
1854        // Tiered reads must see the tombstone with correct target and policy.
1855        let TieredMetadata::Tombstone(t) = backend.get_tiered_metadata(&hv_id).await? else {
1856            panic!("expected TieredMetadata::Tombstone");
1857        };
1858        assert_eq!(t.target, lt_id, "target must round-trip via r column");
1859        assert_eq!(t.expiration_policy, expiration_policy);
1860        match backend.get_tiered_object(&hv_id, None).await? {
1861            TieredGet::Tombstone(t) => assert_eq!(t.target, lt_id, "round-trip via r column"),
1862            other => panic!("expected TieredGet::Tombstone, got {other:?}"),
1863        }
1864
1865        // Legacy reads must error rather than leak tombstone data.
1866        assert!(matches!(
1867            backend.get_object(&hv_id, None).await,
1868            Err(Error::UnexpectedTombstone)
1869        ));
1870        assert!(matches!(
1871            backend.get_metadata(&hv_id).await,
1872            Err(Error::UnexpectedTombstone)
1873        ));
1874
1875        // Idempotent retry: retry with the same target succeeds
1876        let second = backend
1877            .compare_and_write(&hv_id, None, TieredWrite::Tombstone(tombstone))
1878            .await?;
1879        assert!(second, "idempotent retry");
1880
1881        Ok(())
1882    }
1883
1884    /// Swapping a tombstone target: wrong expected → false, correct expected → true.
1885    #[tokio::test]
1886    async fn test_cas_swap_tombstone() -> Result<()> {
1887        let backend = create_test_backend().await?;
1888
1889        let hv_id = make_id();
1890        let old_lt_id = ObjectId::random(hv_id.context().clone());
1891        let wrong_lt_id = ObjectId::random(hv_id.context().clone());
1892        let new_lt_id = ObjectId::random(hv_id.context().clone());
1893
1894        let tombstone = Tombstone {
1895            target: old_lt_id.clone(),
1896            expiration_policy: ExpirationPolicy::Manual,
1897        };
1898        create_tombstone(&backend, &hv_id, &tombstone, SystemTime::now()).await?;
1899
1900        // Wrong target: CAS fails, tombstone unchanged.
1901        let write = TieredWrite::Tombstone(Tombstone {
1902            target: new_lt_id.clone(),
1903            expiration_policy: ExpirationPolicy::Manual,
1904        });
1905        let swapped = backend
1906            .compare_and_write(&hv_id, Some(&wrong_lt_id), write.clone())
1907            .await?;
1908        assert!(!swapped, "expected CAS failure due to wrong target");
1909        match backend.get_tiered_metadata(&hv_id).await? {
1910            TieredMetadata::Tombstone(t) => assert_eq!(t.target, old_lt_id),
1911            other => panic!("expected tombstone, got {other:?}"),
1912        }
1913
1914        // Correct target: CAS succeeds, target updated.
1915        let swapped = backend
1916            .compare_and_write(&hv_id, Some(&old_lt_id), write.clone())
1917            .await?;
1918        assert!(swapped, "expected CAS success with correct target");
1919        match backend.get_tiered_metadata(&hv_id).await? {
1920            TieredMetadata::Tombstone(t) => assert_eq!(t.target, new_lt_id),
1921            other => panic!("expected tombstone, got {other:?}"),
1922        }
1923
1924        // Idempotent retry: same A→B swap returns true.
1925        let retry = backend
1926            .compare_and_write(&hv_id, Some(&old_lt_id), write)
1927            .await?;
1928        assert!(retry, "idempotent retry");
1929
1930        Ok(())
1931    }
1932
1933    /// Swapping a tombstone for inline object data: wrong expected → false, correct → true.
1934    #[tokio::test]
1935    async fn test_cas_swap_inline() -> Result<()> {
1936        let backend = create_test_backend().await?;
1937
1938        let id = make_id();
1939        let lt_id = ObjectId::random(id.context().clone());
1940        let wrong_id = ObjectId::random(id.context().clone());
1941
1942        let tombstone = Tombstone {
1943            target: lt_id.clone(),
1944            expiration_policy: ExpirationPolicy::Manual,
1945        };
1946        create_tombstone(&backend, &id, &tombstone, SystemTime::now()).await?;
1947
1948        // Wrong target: CAS fails, tombstone intact.
1949        let write = TieredWrite::Object(Metadata::default(), Bytes::new());
1950        let swapped = backend
1951            .compare_and_write(&id, Some(&wrong_id), write)
1952            .await?;
1953        assert!(!swapped, "expected CAS failure with wrong target");
1954        assert!(matches!(
1955            backend.get_tiered_metadata(&id).await?,
1956            TieredMetadata::Tombstone(_)
1957        ));
1958
1959        // Correct target: CAS succeeds, row becomes an inline object.
1960        let payload = Bytes::from_static(b"hello inline");
1961        let write = TieredWrite::Object(Metadata::default(), payload.clone());
1962        let swapped = backend
1963            .compare_and_write(&id, Some(&lt_id), write.clone())
1964            .await?;
1965        assert!(swapped, "expected CAS success with correct target");
1966        let TieredGet::Object(_, _, stream) = backend.get_tiered_object(&id, None).await? else {
1967            panic!("expected inline object after swap");
1968        };
1969        assert_eq!(&stream::read_to_vec(stream).await?, payload.as_ref());
1970
1971        // Idempotent retry: row is already inline (no tombstone), same CAS returns true.
1972        let retry = backend.compare_and_write(&id, Some(&lt_id), write).await?;
1973        assert!(retry, "idempotent retry");
1974
1975        Ok(())
1976    }
1977
1978    /// CAS-write an object onto an empty row (expected=None, write=Object) succeeds.
1979    #[tokio::test]
1980    async fn test_cas_create_object_on_empty_row() -> Result<()> {
1981        let backend = create_test_backend().await?;
1982
1983        let id = make_id();
1984        let payload = Bytes::from_static(b"cas object");
1985        let write = TieredWrite::Object(Metadata::default(), payload.clone());
1986        let committed = backend.compare_and_write(&id, None, write).await?;
1987        assert!(committed, "expected CAS success on empty row");
1988
1989        let TieredGet::Object(_, _, stream) = backend.get_tiered_object(&id, None).await? else {
1990            panic!("expected Object after CAS-create");
1991        };
1992        assert_eq!(&stream::read_to_vec(stream).await?, payload.as_ref());
1993
1994        Ok(())
1995    }
1996
1997    /// CAS-delete: wrong expected → false; correct expected → true, row gone.
1998    #[tokio::test]
1999    async fn test_cas_delete() -> Result<()> {
2000        let backend = create_test_backend().await?;
2001
2002        let id = make_id();
2003        let lt_id = ObjectId::random(id.context().clone());
2004        let wrong_id = ObjectId::random(id.context().clone());
2005
2006        let tombstone = Tombstone {
2007            target: lt_id.clone(),
2008            expiration_policy: ExpirationPolicy::Manual,
2009        };
2010        create_tombstone(&backend, &id, &tombstone, SystemTime::now()).await?;
2011
2012        // Wrong target: fails, row preserved.
2013        let deleted = backend
2014            .compare_and_write(&id, Some(&wrong_id), TieredWrite::Delete)
2015            .await?;
2016        assert!(!deleted, "expected CAS failure with wrong target");
2017        assert!(matches!(
2018            backend.get_tiered_metadata(&id).await?,
2019            TieredMetadata::Tombstone(_)
2020        ));
2021
2022        // Correct target: succeeds, row gone.
2023        let deleted = backend
2024            .compare_and_write(&id, Some(&lt_id), TieredWrite::Delete)
2025            .await?;
2026        assert!(deleted, "expected CAS delete success");
2027        assert!(matches!(
2028            backend.get_tiered_metadata(&id).await?,
2029            TieredMetadata::NotFound
2030        ));
2031
2032        // Idempotent retry: row is already absent (no tombstone), same delete returns true.
2033        let retry = backend
2034            .compare_and_write(&id, Some(&lt_id), TieredWrite::Delete)
2035            .await?;
2036        assert!(retry, "idempotent retry");
2037
2038        // Inline object replaced tombstone: Safe to delete since it is an idempotent operation.
2039        let id2 = make_id();
2040        let fake_lt_id = ObjectId::random(id2.context().clone());
2041        let metadata = Metadata::default();
2042        create_object(&backend, &id2, &metadata, b"data", SystemTime::now()).await?;
2043        let deleted = backend
2044            .compare_and_write(&id2, Some(&fake_lt_id), TieredWrite::Delete)
2045            .await?;
2046        assert!(deleted, "expected idempotent deletion");
2047
2048        Ok(())
2049    }
2050
2051    // --- Section 5: Legacy Tombstone Compatibility ---
2052
2053    /// Legacy Manual and TTL tombstones are correctly read via the tiered APIs.
2054    ///
2055    /// Uses `Manual` expiration so `timestamp_micros = -1` (server-assigned ≈ write time)
2056    /// does not trigger immediate expiry.
2057    #[tokio::test]
2058    async fn test_legacy_tombstone_reads() -> Result<()> {
2059        let backend = create_test_backend().await?;
2060
2061        // Manual policy: get_tiered_metadata returns Tombstone(Manual), get_tiered_object returns Tombstone.
2062        let id = make_id();
2063        write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2064
2065        let TieredMetadata::Tombstone(t) = backend.get_tiered_metadata(&id).await? else {
2066            panic!("expected tombstone");
2067        };
2068        assert_eq!(t.expiration_policy, ExpirationPolicy::Manual);
2069        assert!(matches!(
2070            backend.get_tiered_object(&id, None).await?,
2071            TieredGet::Tombstone(_)
2072        ));
2073
2074        // TTL policy: get_tiered_metadata returns Tombstone with the correct TTL policy.
2075        //
2076        // A future cell timestamp (now + TTL) is required so `expires_before` does not
2077        // immediately filter the row.
2078        let id = make_id();
2079        let ttl = Duration::from_hours(2 * 24);
2080        write_legacy_tombstone(&backend, &id, ExpirationPolicy::TimeToLive(ttl), None).await?;
2081
2082        let TieredMetadata::Tombstone(t) = backend.get_tiered_metadata(&id).await? else {
2083            panic!("expected TieredMetadata::Tombstone");
2084        };
2085        assert_eq!(t.expiration_policy, ExpirationPolicy::TimeToLive(ttl));
2086
2087        Ok(())
2088    }
2089
2090    /// A legacy tombstone with TTI policy is upgraded to the new `r`/`t` column format on read.
2091    ///
2092    /// The bump path calls `put_tombstone_row`, which rewrites the row with `r` + `t` columns.
2093    /// The upgraded row has a fresh cell timestamp (≈ now + TTI), so `time_expires` increases.
2094    #[tokio::test]
2095    async fn test_legacy_tombstone_tti_upgrade() -> Result<()> {
2096        let backend = create_test_backend().await?;
2097        let id = make_id();
2098        let path = id.as_storage_path().to_string().into_bytes();
2099
2100        let tti = Duration::from_hours(2 * 24); // must exceed TTI_DEBOUNCE (1 day)
2101
2102        // Place time_expires just inside the bump window: past `now + tti - TTI_DEBOUNCE`
2103        // but still in the future so `expires_before(now)` does not filter the row.
2104        let old_deadline = SystemTime::now() + tti - TTI_DEBOUNCE - Duration::from_mins(1);
2105        write_legacy_tombstone(
2106            &backend,
2107            &id,
2108            ExpirationPolicy::TimeToIdle(tti),
2109            Some(old_deadline),
2110        )
2111        .await?;
2112
2113        // First read detects the stale TTI and triggers `put_tombstone_row`.
2114        let TieredMetadata::Tombstone(_) = backend.get_tiered_metadata(&id).await? else {
2115            panic!("expected tombstone");
2116        };
2117
2118        // After the bump, the row is rewritten with a fresh timestamp (≈ now + TTI).
2119        let new_deadline = match backend.read_row(&path, None, "test-verify").await? {
2120            Some(RowData::Tombstone { time_expires, .. }) => time_expires.unwrap(),
2121            _ => panic!("expected tombstone row after bump"),
2122        };
2123
2124        assert!(
2125            new_deadline > old_deadline,
2126            "TTI bump should extend tombstone expiry: {old_deadline:?} -> {new_deadline:?}"
2127        );
2128
2129        Ok(())
2130    }
2131
2132    /// Legacy tombstones are handled correctly by all conditional write operations.
2133    ///
2134    /// Covers: `put_non_tombstone`, `delete_non_tombstone`, CAS-delete for both the
2135    /// legacy-metadata format and the empty-redirect format.
2136    #[tokio::test]
2137    async fn test_legacy_tombstone_conditional_ops() -> Result<()> {
2138        let backend = create_test_backend().await?;
2139
2140        // put_non_tombstone returns Some(target == id) for a legacy tombstone.
2141        let id = make_id();
2142        write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2143        let t_opt = backend
2144            .put_non_tombstone(&id, &Metadata::default(), Bytes::new())
2145            .await?;
2146        assert_eq!(t_opt.map(|t| t.target).as_ref(), Some(&id));
2147
2148        // delete_non_tombstone returns Some(target == id) for a legacy tombstone.
2149        let id = make_id();
2150        write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2151        let t_opt = backend.delete_non_tombstone(&id).await?;
2152        assert_eq!(t_opt.map(|t| t.target).as_ref(), Some(&id));
2153
2154        // CAS-delete succeeds on a legacy-metadata tombstone (target resolves to hv_id).
2155        let id = make_id();
2156        write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2157        let deleted = backend
2158            .compare_and_write(&id, Some(&id), TieredWrite::Delete)
2159            .await?;
2160        assert!(
2161            deleted,
2162            "CAS-delete must succeed on legacy-metadata tombstone"
2163        );
2164        assert!(matches!(
2165            backend.get_tiered_metadata(&id).await?,
2166            TieredMetadata::NotFound
2167        ));
2168
2169        // CAS-delete succeeds on an empty-redirect tombstone (target resolves to hv_id).
2170        let id = make_id();
2171        write_empty_redirect_tombstone(&backend, &id).await?;
2172        let deleted = backend
2173            .compare_and_write(&id, Some(&id), TieredWrite::Delete)
2174            .await?;
2175        assert!(
2176            deleted,
2177            "CAS-delete must succeed on empty-redirect tombstone"
2178        );
2179        assert!(matches!(
2180            backend.get_tiered_metadata(&id).await?,
2181            TieredMetadata::NotFound
2182        ));
2183
2184        Ok(())
2185    }
2186
2187    /// An empty `r` value falls back to the HV id when resolving the tombstone target.
2188    #[tokio::test]
2189    async fn test_empty_redirect_falls_back_to_hv_id() -> Result<()> {
2190        let backend = create_test_backend().await?;
2191        let id = make_id();
2192
2193        write_empty_redirect_tombstone(&backend, &id).await?;
2194        match backend.get_tiered_metadata(&id).await? {
2195            TieredMetadata::Tombstone(t) => assert_eq!(t.target, id, "must fall back to hv_id"),
2196            other => panic!("expected tombstone, got {other:?}"),
2197        }
2198
2199        Ok(())
2200    }
2201
2202    // --- Section 6: Expired Tombstone Handling ---
2203
2204    /// CAS with `current=None` must succeed when the row holds an expired
2205    /// tombstone. The physical row still exists but is logically gone.
2206    #[tokio::test]
2207    async fn test_cas_create_tombstone_over_expired() -> Result<()> {
2208        let backend = create_test_backend().await?;
2209
2210        let id = make_id();
2211        let old_lt_id = ObjectId::random(id.context().clone());
2212        let old_tombstone = Tombstone {
2213            target: old_lt_id,
2214            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
2215        };
2216        create_tombstone(&backend, &id, &old_tombstone, SystemTime::now()).await?;
2217
2218        let new_lt_id = ObjectId::random(id.context().clone());
2219        let new_tombstone = Tombstone {
2220            target: new_lt_id.clone(),
2221            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
2222        };
2223        let committed = backend
2224            .compare_and_write(&id, None, TieredWrite::Tombstone(new_tombstone))
2225            .await?;
2226        assert!(
2227            committed,
2228            "CAS with current=None must succeed over an expired tombstone"
2229        );
2230
2231        let TieredMetadata::Tombstone(t) = backend.get_tiered_metadata(&id).await? else {
2232            panic!("expected new tombstone to be readable");
2233        };
2234        assert_eq!(t.target, new_lt_id);
2235
2236        Ok(())
2237    }
2238
2239    /// `put_non_tombstone` must succeed when the row holds only an expired
2240    /// tombstone — the expired row is logically absent.
2241    #[tokio::test]
2242    async fn test_put_non_tombstone_over_expired() -> Result<()> {
2243        let backend = create_test_backend().await?;
2244
2245        let id = make_id();
2246        let lt_id = ObjectId::random(id.context().clone());
2247        let tombstone = Tombstone {
2248            target: lt_id,
2249            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
2250        };
2251        create_tombstone(&backend, &id, &tombstone, SystemTime::now()).await?;
2252
2253        let result = backend
2254            .put_non_tombstone(&id, &Metadata::default(), Bytes::from_static(b"data"))
2255            .await?;
2256        assert_eq!(
2257            result, None,
2258            "put_non_tombstone must succeed (return None) over an expired tombstone"
2259        );
2260
2261        let (_, _, stream) = backend.get_object(&id, None).await?.unwrap();
2262        assert_eq!(&stream::read_to_vec(stream).await?, b"data");
2263
2264        Ok(())
2265    }
2266
2267    // --- Range Request Tests ---
2268
2269    async fn put_range_test_object(backend: &BigTableBackend) -> Result<ObjectId> {
2270        let id = make_id();
2271        let metadata = Metadata {
2272            content_type: "text/plain".into(),
2273            ..Default::default()
2274        };
2275        let payload = b"Hello, range requests!";
2276        backend
2277            .put_object(&id, &metadata, stream::single(payload.as_slice()))
2278            .await?;
2279        Ok(id)
2280    }
2281
2282    #[tokio::test]
2283    async fn get_object_range_bounded() -> Result<()> {
2284        let backend = create_test_backend().await?;
2285        let id = put_range_test_object(&backend).await?;
2286
2287        let (_, content_range, stream) = backend
2288            .get_object(&id, Some(ByteRange::Bounded(7, 11)))
2289            .await?
2290            .unwrap();
2291        let data = stream::read_to_vec(stream).await?;
2292        assert_eq!(&data, b"range");
2293
2294        let content_range = content_range.unwrap();
2295        assert_eq!(content_range.start, 7);
2296        assert_eq!(content_range.end, 11);
2297        assert_eq!(content_range.total, 22);
2298
2299        Ok(())
2300    }
2301
2302    #[tokio::test]
2303    async fn get_object_range_from() -> Result<()> {
2304        let backend = create_test_backend().await?;
2305        let id = put_range_test_object(&backend).await?;
2306
2307        let (_, content_range, stream) = backend
2308            .get_object(&id, Some(ByteRange::From(7)))
2309            .await?
2310            .unwrap();
2311        let data = stream::read_to_vec(stream).await?;
2312        assert_eq!(&data, b"range requests!");
2313
2314        let content_range = content_range.unwrap();
2315        assert_eq!(content_range.start, 7);
2316        assert_eq!(content_range.end, 21);
2317        assert_eq!(content_range.total, 22);
2318
2319        Ok(())
2320    }
2321
2322    #[tokio::test]
2323    async fn get_object_range_last() -> Result<()> {
2324        let backend = create_test_backend().await?;
2325        let id = put_range_test_object(&backend).await?;
2326
2327        let (_, content_range, stream) = backend
2328            .get_object(&id, Some(ByteRange::Last(9)))
2329            .await?
2330            .unwrap();
2331        let data = stream::read_to_vec(stream).await?;
2332        assert_eq!(&data, b"requests!");
2333
2334        let content_range = content_range.unwrap();
2335        assert_eq!(content_range.start, 13);
2336        assert_eq!(content_range.end, 21);
2337        assert_eq!(content_range.total, 22);
2338
2339        Ok(())
2340    }
2341
2342    #[tokio::test]
2343    async fn get_object_range_unsatisfiable() -> Result<()> {
2344        let backend = create_test_backend().await?;
2345        let id = put_range_test_object(&backend).await?;
2346
2347        match backend.get_object(&id, Some(ByteRange::From(100))).await {
2348            Err(Error::RangeNotSatisfiable { total }) => assert_eq!(total, 22),
2349            Ok(_) => panic!("expected RangeNotSatisfiable, got Ok"),
2350            Err(e) => panic!("expected RangeNotSatisfiable, got {e:?}"),
2351        }
2352
2353        Ok(())
2354    }
2355
2356    #[tokio::test]
2357    async fn get_object_no_range_returns_full_payload() -> Result<()> {
2358        let backend = create_test_backend().await?;
2359        let id = put_range_test_object(&backend).await?;
2360
2361        let (_, content_range, stream) = backend.get_object(&id, None).await?.unwrap();
2362        let data = stream::read_to_vec(stream).await?;
2363        assert_eq!(&data, b"Hello, range requests!");
2364        assert!(content_range.is_none());
2365
2366        Ok(())
2367    }
2368}