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