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//!
15//! The `r` column signals a tombstone row: its **value** is the long-term `ObjectId`
16//! serialized via `as_storage_path()`. Callers can resolve the LT object directly from the
17//! `r` value without reconstructing it from the row key. Its column family and timestamp
18//! carry the tombstone's concrete expiration deadline.
19//!
20//! `p`/`m` and `r` 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` 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; a successful conditional
29//! expiry extension upgrades them to the `r` format. Tombstone metadata in the historical `t`
30//! column is ignored; the corresponding `r` cell contains all information needed by readers.
31
32use std::fmt;
33use std::future::Future;
34use std::sync::Arc;
35use std::time::Duration;
36
37use bigtable_rs::bigtable::{BigTableConnection, Error as BigTableError, RowCell};
38use bigtable_rs::google::bigtable::v2::{self, mutation};
39use bytes::Bytes;
40use futures_util::TryStreamExt;
41use objectstore_types::metadata::Metadata;
42use objectstore_types::range::{ByteRange, ContentRange};
43use objectstore_types::time::Timestamp;
44use serde::{Deserialize, Serialize};
45use tonic::Code;
46use tracing::Instrument;
47
48use crate::backend::common::{
49    Backend, DeleteResponse, GetResponse, HighVolumeBackend, MetadataResponse, PutResponse,
50    TieredGet, TieredMetadata, TieredUpdate, TieredWrite, Tombstone,
51};
52use crate::change_stream::{
53    ChangeStream, ChangeStreamFactory, CostTrackerStreamConfig, flush_change_stream,
54};
55use crate::error::{Error, ErrorKind, Result, ResultExt as _};
56use crate::gcp_auth::PrefetchingTokenProvider;
57use crate::id::ObjectId;
58use crate::stream::{ChunkedBytes, ClientStream};
59
60/// Configuration for [`BigTableBackend`].
61///
62/// Stores objects in [Google Cloud Bigtable], a NoSQL wide-column database optimized for
63/// high-throughput, low-latency workloads with small objects. Authentication uses Application
64/// Default Credentials (ADC).
65///
66/// **Note**: The table must be pre-created with the following column families:
67/// - `fg`: timestamp-based garbage collection (`maxage=1s`)
68/// - `fm`: manual garbage collection (`no GC policy`)
69///
70/// [Google Cloud Bigtable]: https://cloud.google.com/bigtable
71///
72/// # Example
73///
74/// ```yaml
75/// storage:
76///   type: bigtable
77///   project_id: my-project
78///   instance_name: objectstore
79///   table_name: objectstore
80/// ```
81#[derive(Debug, Clone, Deserialize, Serialize)]
82pub struct BigTableConfig {
83    /// Optional custom Bigtable endpoint.
84    ///
85    /// Useful for testing with emulators. If `None`, uses the default Bigtable endpoint.
86    ///
87    /// # Default
88    ///
89    /// `None` (uses default Bigtable endpoint)
90    ///
91    /// # Environment Variables
92    ///
93    /// - `OS__STORAGE__TYPE=bigtable`
94    /// - `OS__STORAGE__ENDPOINT=localhost:8086` (optional)
95    pub endpoint: Option<String>,
96
97    /// GCP project ID.
98    ///
99    /// The Google project ID (not project number) containing the Bigtable instance.
100    ///
101    /// # Environment Variables
102    ///
103    /// - `OS__STORAGE__PROJECT_ID=my-project`
104    pub project_id: String,
105
106    /// Bigtable instance name.
107    ///
108    /// # Environment Variables
109    ///
110    /// - `OS__STORAGE__INSTANCE_NAME=my-instance`
111    pub instance_name: String,
112
113    /// Bigtable table name.
114    ///
115    /// The table must exist before starting the server.
116    ///
117    /// # Environment Variables
118    ///
119    /// - `OS__STORAGE__TABLE_NAME=objectstore`
120    pub table_name: String,
121
122    /// Optional number of connections to maintain to Bigtable.
123    ///
124    /// # Default
125    ///
126    /// `None` (defaults to 1)
127    ///
128    /// # Environment Variables
129    ///
130    /// - `OS__STORAGE__CONNECTIONS=16` (optional)
131    pub connections: Option<usize>,
132
133    /// Timeout for an individual Bigtable RPC attempt.
134    ///
135    /// # Default
136    ///
137    /// `2s`
138    ///
139    /// # Environment Variables
140    ///
141    /// - `OS__STORAGE__RPC_TIMEOUT=2s`
142    /// - `OS__STORAGE__HIGH_VOLUME__RPC_TIMEOUT=2s` (tiered storage)
143    #[serde(default = "default_rpc_timeout", with = "humantime_serde")]
144    pub rpc_timeout: Duration,
145
146    /// Reports what this backend stores, for per-usecase cost attribution.
147    ///
148    /// # Default
149    ///
150    /// `None`, which disables reporting for this backend.
151    ///
152    /// # Environment Variables
153    ///
154    /// - `OS__STORAGE__COGS__SHARED_RESOURCE_ID=bigtable_objectstore`
155    /// - `OS__STORAGE__COGS__SAMPLE_RATE=1.0` (optional)
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub cogs: Option<CostTrackerStreamConfig>,
158}
159
160fn default_rpc_timeout() -> Duration {
161    Duration::from_secs(2)
162}
163
164/// Maximum age for connections (GRPC channels) to Bigtable, after which they will be swapped with
165/// new ones in the background.
166/// This is intended to avoid latency spikes that could occur every hour or so, when the server
167/// 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)).
168/// `tonic` already handles reconnections transparently, but lazily, meaning that the first requests
169/// that attempt to use a certain channel after the server has closed it will pay the cost of the
170/// reconnection, resulting in increased latency for those requests.
171const MAX_CHANNEL_AGE: Option<Duration> = Some(Duration::from_mins(50));
172/// Permission scopes required for accessing the BigTable data API.
173const TOKEN_SCOPES: &[&str] = &["https://www.googleapis.com/auth/bigtable.data"];
174
175/// How often to retry failed requests.
176const REQUEST_RETRY_COUNT: usize = 2;
177/// How many times to retry a CAS mutation before giving up and returning an error.
178const CAS_RETRY_COUNT: usize = 3;
179
180/// Column that stores the raw payload (compressed).
181const COLUMN_PAYLOAD: &[u8] = b"p";
182/// Column that stores metadata in JSON.
183const COLUMN_METADATA: &[u8] = b"m";
184/// Column that stores the redirect path for tombstone rows.
185const COLUMN_REDIRECT: &[u8] = b"r";
186/// Regex to match all non-payload columns (`m`, `r`) for metadata-only reads.
187const FILTER_META: &[u8] = b"^[mr]$";
188
189/// Column family that uses timestamp-based garbage collection.
190///
191/// We require a GC rule on this family to automatically delete rows.
192/// See: <https://cloud.google.com/bigtable/docs/gc-cell-level>
193const FAMILY_GC: &str = "fg";
194/// Column family that uses manual garbage collection.
195const FAMILY_MANUAL: &str = "fm";
196
197/// BigTable storage backend for high-volume, low-latency object storage.
198pub struct BigTableBackend {
199    bigtable: BigTableConnection,
200
201    instance_path: String,
202    table_path: String,
203    table_name: String,
204
205    change_stream: Arc<dyn ChangeStream>,
206}
207
208impl fmt::Debug for BigTableBackend {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        f.debug_struct("BigTableBackend")
211            .field("instance_path", &self.instance_path)
212            .field("table_path", &self.table_path)
213            .field("table_name", &self.table_name)
214            .finish_non_exhaustive()
215    }
216}
217
218/// Creates a row filter that matches a single column by exact qualifier.
219fn column_filter(column: &[u8]) -> v2::RowFilter {
220    v2::RowFilter {
221        filter: Some(v2::row_filter::Filter::ColumnQualifierRegexFilter(
222            [b"^", column, b"$"].concat(),
223        )),
224    }
225}
226
227/// Creates a row filter matching the legacy tombstone format: `m` column JSON starts with
228/// `{"is_redirect_tombstone":true`.
229///
230/// After legacy tombstones expire naturally this filter becomes dead code in both callers.
231fn legacy_tombstone_filter() -> v2::RowFilter {
232    v2::RowFilter {
233        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
234            filters: vec![
235                column_filter(COLUMN_METADATA),
236                v2::RowFilter {
237                    filter: Some(v2::row_filter::Filter::ValueRegexFilter(
238                        b"^\\{\"is_redirect_tombstone\":true[,}].*".to_vec(),
239                    )),
240                },
241            ],
242        })),
243    }
244}
245
246/// Wraps `inner` so that it only matches live (non-expired) cells.
247///
248/// Uses the rounded access time directly. Legacy fractional cell timestamps can be excluded
249/// up to one second before their rounded metadata deadline; new writes are second-aligned.
250fn live_row_filter(inner: v2::RowFilter, now: Timestamp) -> v2::RowFilter {
251    v2::RowFilter {
252        filter: Some(v2::row_filter::Filter::Interleave(
253            v2::row_filter::Interleave {
254                filters: vec![
255                    // Manual family: never expires.
256                    v2::RowFilter {
257                        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
258                            filters: vec![
259                                v2::RowFilter {
260                                    filter: Some(v2::row_filter::Filter::FamilyNameRegexFilter(
261                                        format!("^{FAMILY_MANUAL}$"),
262                                    )),
263                                },
264                                inner.clone(),
265                            ],
266                        })),
267                    },
268                    // GC family: only match non-expired cells.
269                    v2::RowFilter {
270                        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
271                            filters: vec![
272                                v2::RowFilter {
273                                    filter: Some(v2::row_filter::Filter::FamilyNameRegexFilter(
274                                        format!("^{FAMILY_GC}$"),
275                                    )),
276                                },
277                                v2::RowFilter {
278                                    filter: Some(v2::row_filter::Filter::TimestampRangeFilter(
279                                        v2::TimestampRange {
280                                            start_timestamp_micros: now.as_micros() as i64,
281                                            end_timestamp_micros: 0,
282                                        },
283                                    )),
284                                },
285                                inner,
286                            ],
287                        })),
288                    },
289                ],
290            },
291        )),
292    }
293}
294
295/// Builds a raw row filter that matches any live tombstone row, new- or legacy-format.
296///
297/// New format: presence of the `r` column.
298/// Legacy format: `is_redirect_tombstone: true` in the `m` column JSON.
299///
300/// After legacy tombstones expire naturally this simplifies to just
301/// `column_filter(COLUMN_REDIRECT)`.
302fn tombstone_filter(access_time: Timestamp) -> v2::RowFilter {
303    let filter = v2::RowFilter {
304        filter: Some(v2::row_filter::Filter::Interleave(
305            v2::row_filter::Interleave {
306                filters: vec![column_filter(COLUMN_REDIRECT), legacy_tombstone_filter()],
307            },
308        )),
309    };
310    live_row_filter(filter, access_time)
311}
312
313/// Returns a [`MutatePredicate`] that matches any live tombstone row.
314///
315/// Mutations will not run on live tombstones (`predicate_matched == false`). They _will_
316/// run on expired tombstones as well as non-tombstones. Used by
317/// [`BigTableBackend::put_non_tombstone`] and [`BigTableBackend::compare_and_write`] as
318/// the `CheckAndMutateRow` predicate.
319///
320/// This predicate cannot distinguish an empty row from a row holding a regular object; a caller
321/// that needs to know whether its mutation hit anything wants [`non_tombstone_predicate`].
322fn tombstone_predicate(access_time: Timestamp) -> MutatePredicate {
323    MutatePredicate::Exclude(tombstone_filter(access_time))
324}
325
326/// Returns a [`MutatePredicate`] that is the logical negation of [`tombstone_predicate`];
327/// it matches everything _except_ live tombstones.
328///
329/// Mutations run only when the predicate matches (`predicate_matched == true`). They will
330/// run on expired tombstones as well as non-tombstones. The match result doubles as a
331/// "was a row removed?" signal. Used by [`BigTableBackend::delete_non_tombstone`] as the
332/// `CheckAndMutateRow` predicate.
333///
334/// Built as a `Condition` filter:
335/// - Predicate: [`tombstone_filter`] -> is a live tombstone present?
336/// - True branch: `BlockAllFilter` -> match nothing; live tombstones should be preserved
337/// - False branch: `PassAllFilter` -> match everything: expired tombstones and non-tombstones
338fn non_tombstone_predicate(access_time: Timestamp) -> MutatePredicate {
339    MutatePredicate::Include(v2::RowFilter {
340        filter: Some(v2::row_filter::Filter::Condition(Box::new(
341            v2::row_filter::Condition {
342                predicate_filter: Some(Box::new(tombstone_filter(access_time))),
343                true_filter: Some(Box::new(v2::RowFilter {
344                    filter: Some(v2::row_filter::Filter::BlockAllFilter(true)),
345                })),
346                false_filter: Some(Box::new(v2::RowFilter {
347                    filter: Some(v2::row_filter::Filter::PassAllFilter(true)),
348                })),
349            },
350        ))),
351    })
352}
353
354/// Builds an anchored regex pattern (`^…$`) that matches `value` literally.
355///
356/// Uses [`regex::escape`] so that metacharacters in storage paths (`.`, `/`, etc.)
357/// are treated as literal bytes.
358fn exact_value_regex(value: &str) -> Vec<u8> {
359    format!("^{}$", regex::escape(value)).into_bytes()
360}
361
362/// Matches tombstones whose redirect resolves to `target`.
363///
364/// ## Predicate Matches
365///
366/// Must be used with `true_mutations` and `predicate_matched == true`.
367///
368/// ## Details
369///
370/// Always includes an exact match on the `r` (redirect) column:
371/// - Chain: `r` column present AND value == `target` storage path
372///
373/// When `target == own_id` (the caller expects a legacy identity redirect), the
374/// exact match is wrapped in an Interleave with two additional fallbacks:
375/// - Chain: `r` column present AND value == `b""` (empty-sentinel written before the redirect
376///   column stored the path)
377/// - Chain: `m` column present AND value matches `{"is_redirect_tombstone":true...}` regex
378///   (legacy metadata format predating the dedicated `r` column)
379fn redirect_target_filter(
380    target: &ObjectId,
381    own_id: &ObjectId,
382    access_time: Timestamp,
383) -> v2::RowFilter {
384    let target_path = exact_value_regex(&target.as_storage_path().to_string());
385
386    let exact_match = v2::RowFilter {
387        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
388            filters: vec![
389                column_filter(COLUMN_REDIRECT),
390                v2::RowFilter {
391                    filter: Some(v2::row_filter::Filter::ValueRegexFilter(target_path)),
392                },
393            ],
394        })),
395    };
396
397    if target != own_id {
398        return live_row_filter(exact_match, access_time);
399    }
400
401    let empty_redirect_match = v2::RowFilter {
402        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
403            filters: vec![
404                column_filter(COLUMN_REDIRECT),
405                v2::RowFilter {
406                    filter: Some(v2::row_filter::Filter::ValueRegexFilter(b"^$".to_vec())),
407                },
408            ],
409        })),
410    };
411
412    // Also match legacy tombstones that resolve to the HV id:
413    // - empty `r` value (written before the redirect column stored the path)
414    // - legacy `m` column format (`is_redirect_tombstone: true`)
415    let filter = v2::RowFilter {
416        filter: Some(v2::row_filter::Filter::Interleave(
417            v2::row_filter::Interleave {
418                filters: vec![exact_match, empty_redirect_match, legacy_tombstone_filter()],
419            },
420        )),
421    };
422    live_row_filter(filter, access_time)
423}
424
425/// Returns a [`MutatePredicate`] that matches tombstones whose redirect resolves to either `old` or `new`.
426///
427/// Mutations run only when the predicate matches (`predicate_matched == true`):
428/// equivalent to `t == old || t == new`. Built as an Interleave of two
429/// [`redirect_target_filter`] calls — yields cells iff at least one branch matches.
430/// An absent row or non-tombstone row yields 0 cells, so `predicate_matched = false` (conflict).
431fn update_predicate(
432    old: &ObjectId,
433    new: &ObjectId,
434    own_id: &ObjectId,
435    access_time: Timestamp,
436) -> MutatePredicate {
437    MutatePredicate::Include(v2::RowFilter {
438        filter: Some(v2::row_filter::Filter::Interleave(
439            v2::row_filter::Interleave {
440                filters: vec![
441                    redirect_target_filter(old, own_id, access_time),
442                    redirect_target_filter(new, own_id, access_time),
443                ],
444            },
445        )),
446    })
447}
448
449/// Returns a [`MutatePredicate`] that matches rows where no conflicting tombstone exists.
450///
451/// Mutations run only when the row is conflict-free (`predicate_matched == false`):
452/// no tombstone is present, or the tombstone's redirect already points to `target`.
453///
454/// Built as an inverted `Condition` filter:
455/// - Predicate: [`redirect_target_filter`]`(target)` — tombstone already points to `target`?
456/// - True branch: `BlockAllFilter` → 0 cells (already at target, safe state).
457/// - False branch: [`tombstone_filter`] → 0 cells when no tombstone exists.
458///
459/// Both safe states yield 0 cells, so `predicate_matched = false` in both cases.
460fn optional_target_predicate(
461    target: &ObjectId,
462    own_id: &ObjectId,
463    access_time: Timestamp,
464) -> MutatePredicate {
465    MutatePredicate::Exclude(v2::RowFilter {
466        filter: Some(v2::row_filter::Filter::Condition(Box::new(
467            v2::row_filter::Condition {
468                predicate_filter: Some(Box::new(redirect_target_filter(
469                    target,
470                    own_id,
471                    access_time,
472                ))),
473                true_filter: Some(Box::new(v2::RowFilter {
474                    filter: Some(v2::row_filter::Filter::BlockAllFilter(true)),
475                })),
476                false_filter: Some(Box::new(tombstone_filter(access_time))),
477            },
478        ))),
479    })
480}
481
482fn exact_expiry_filter(start: i64) -> Result<v2::RowFilter> {
483    let end = start.checked_add(1).ok_or_else(|| {
484        Error::new(
485            ErrorKind::Internal,
486            "building Bigtable expiration predicate",
487        )
488    })?;
489    Ok(v2::RowFilter {
490        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
491            filters: vec![
492                v2::RowFilter {
493                    filter: Some(v2::row_filter::Filter::FamilyNameRegexFilter(format!(
494                        "^{FAMILY_GC}$"
495                    ))),
496                },
497                v2::RowFilter {
498                    filter: Some(v2::row_filter::Filter::TimestampRangeFilter(
499                        v2::TimestampRange {
500                            start_timestamp_micros: start,
501                            end_timestamp_micros: end,
502                        },
503                    )),
504                },
505            ],
506        })),
507    })
508}
509
510/// Matches an inline row whose metadata cell has the observed expiry timestamp.
511fn inline_expiry_predicate(
512    observed_expiry: i64,
513    access_time: Timestamp,
514) -> Result<MutatePredicate> {
515    let inline_at_expiry = v2::RowFilter {
516        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
517            filters: vec![
518                column_filter(COLUMN_METADATA),
519                exact_expiry_filter(observed_expiry)?,
520            ],
521        })),
522    };
523
524    Ok(MutatePredicate::Include(v2::RowFilter {
525        filter: Some(v2::row_filter::Filter::Condition(Box::new(
526            v2::row_filter::Condition {
527                predicate_filter: Some(Box::new(tombstone_filter(access_time))),
528                true_filter: Some(Box::new(v2::RowFilter {
529                    filter: Some(v2::row_filter::Filter::BlockAllFilter(true)),
530                })),
531                false_filter: Some(Box::new(inline_at_expiry)),
532            },
533        ))),
534    }))
535}
536
537fn redirect_expiry_predicate(
538    target: &ObjectId,
539    own_id: &ObjectId,
540    observed_expiry: i64,
541    access_time: Timestamp,
542) -> Result<MutatePredicate> {
543    Ok(MutatePredicate::Include(v2::RowFilter {
544        filter: Some(v2::row_filter::Filter::Chain(v2::row_filter::Chain {
545            filters: vec![
546                redirect_target_filter(target, own_id, access_time),
547                exact_expiry_filter(observed_expiry)?,
548            ],
549        })),
550    }))
551}
552
553/// The condition under which a [`BigTableBackend::check_and_mutate`] write proceeds.
554///
555/// Each variant pairs a row filter with the state that makes the write safe:
556/// `Include` writes when the row matches; `Exclude` writes when it does not.
557#[derive(Clone, Debug)]
558enum MutatePredicate {
559    /// Write proceeds when the filter matches the row.
560    ///
561    /// Mutations run in `true_mutations`; succeeds when `predicate_matched == true`.
562    Include(v2::RowFilter),
563    /// Write proceeds when the filter does not match the row.
564    ///
565    /// Mutations run in `false_mutations`; succeeds when `predicate_matched == false`.
566    Exclude(v2::RowFilter),
567}
568
569/// Creates a row filter that reads all non-payload columns (`m`, `r`).
570///
571/// Used by metadata-only reads to avoid fetching the (potentially large) payload column
572/// while still being able to detect both new- and legacy-format tombstones.
573fn metadata_filter() -> v2::RowFilter {
574    v2::RowFilter {
575        filter: Some(v2::row_filter::Filter::ColumnQualifierRegexFilter(
576            FILTER_META.to_owned(),
577        )),
578    }
579}
580
581fn mutation(mutation: mutation::Mutation) -> v2::Mutation {
582    v2::Mutation {
583        mutation: Some(mutation),
584    }
585}
586
587/// Creates a `DeleteFromRow` mutation wrapped in the outer [`v2::Mutation`] envelope.
588fn delete_row_mutation() -> v2::Mutation {
589    mutation(mutation::Mutation::DeleteFromRow(
590        mutation::DeleteFromRow {},
591    ))
592}
593
594/// Builds the three mutations that write an object row: clear existing data,
595/// then set the payload and metadata cells. Returns them with the resulting row size.
596///
597/// Used by both [`BigTableBackend::put_row`] (unconditional write) and
598/// [`BigTableBackend::put_non_tombstone`] (conditional write).
599fn object_mutations(
600    path: &[u8],
601    mut metadata: Metadata,
602    payload: Vec<u8>,
603) -> Result<([v2::Mutation; 3], u64)> {
604    let (family, timestamp_micros) = match metadata.time_expires {
605        None => (FAMILY_MANUAL, -1),
606        Some(deadline) => (FAMILY_GC, deadline.as_micros() as i64),
607    };
608
609    // Record the payload size in the metadata before persisting it.
610    metadata.size = Some(payload.len());
611
612    let metadata_bytes = serde_json::to_vec(&metadata)
613        .context(ErrorKind::Internal, "encoding Bigtable object metadata")?;
614
615    let mutations = [
616        // NB: We explicitly delete the row to clear metadata on overwrite.
617        delete_row_mutation(),
618        mutation(mutation::Mutation::SetCell(mutation::SetCell {
619            family_name: family.to_owned(),
620            column_qualifier: COLUMN_PAYLOAD.to_owned(),
621            timestamp_micros,
622            value: payload,
623        })),
624        mutation(mutation::Mutation::SetCell(mutation::SetCell {
625            family_name: family.to_owned(),
626            column_qualifier: COLUMN_METADATA.to_owned(),
627            timestamp_micros,
628            value: metadata_bytes,
629        })),
630    ];
631
632    let size = row_size(path, &mutations);
633    Ok((mutations, size))
634}
635
636/// Approximates the bytes a row occupies, as its key plus every cell value written.
637///
638/// This function does not distinguish between object rows and tombstone rows. It does not
639/// include Bigtable's own overhead.
640fn row_size(path: &[u8], mutations: &[v2::Mutation]) -> u64 {
641    let cells: usize = mutations
642        .iter()
643        .filter_map(|m| match &m.mutation {
644            Some(mutation::Mutation::SetCell(cell)) => Some(cell.value.len()),
645            _ => None,
646        })
647        .sum();
648
649    (path.len() + cells) as u64
650}
651
652/// Builds the two mutations that write a tombstone row: clear existing data,
653/// then set the redirect cell.
654///
655/// Used by both unconditional tombstone writes and the conditional expiry-extension paths.
656fn tombstone_mutations(tombstone: &Tombstone) -> [v2::Mutation; 2] {
657    let (family, timestamp_micros) = match tombstone.time_expires {
658        None => (FAMILY_MANUAL, -1),
659        Some(deadline) => (FAMILY_GC, deadline.as_micros() as i64),
660    };
661
662    [
663        delete_row_mutation(),
664        mutation(mutation::Mutation::SetCell(mutation::SetCell {
665            family_name: family.to_owned(),
666            column_qualifier: COLUMN_REDIRECT.to_owned(),
667            timestamp_micros,
668            value: tombstone.target.as_storage_path().to_string().into_bytes(),
669        })),
670    ]
671}
672
673/// Subset of [`Metadata`] that indicates a row is a tombstone instead of a real object.
674///
675/// Used to construct [`RowData`].
676#[derive(Debug, Deserialize)]
677struct LegacyTombstoneMeta {
678    /// Internal redirect tombstone marker.
679    ///
680    /// When `true`, this object is a legacy tombstone. This implies:
681    ///  - the payload is empty
682    ///  - metadata is not meaningful
683    ///  - the `r` column is not present
684    #[serde(default)]
685    is_redirect_tombstone: bool,
686}
687
688/// Parsed data from a BigTable row's cells.
689enum RowData {
690    /// A regular object row with payload and metadata.
691    Object {
692        metadata: Metadata,
693        payload: Vec<u8>,
694        /// Original GC timestamp for exact CAS matching, including legacy fractional seconds.
695        expiry_micros: i64,
696    },
697    /// A tombstone row indicating the real payload lives on the long-term backend.
698    Tombstone {
699        target: Vec<u8>,
700        time_expires: Option<Timestamp>,
701        /// Original GC timestamp for exact CAS matching, including legacy fractional seconds.
702        expiry_micros: i64,
703    },
704}
705
706impl RowData {
707    /// Parses a set of row cells into a [`RowData`].
708    ///
709    /// New-format tombstones are identified by the presence of the `r` column.
710    /// Legacy tombstones (written before the column migration) are identified by
711    /// `is_redirect_tombstone: true` in the `m` column JSON; a
712    /// `bigtable.legacy_tombstone_read` metric is emitted on each such read.
713    fn from_cells(cells: Vec<RowCell>) -> Result<Self> {
714        let mut metadata_opt: Option<Metadata> = None;
715        let mut redirect_detected = false;
716        let mut redirect_target = Vec::new();
717        let mut expire_at = None;
718        let mut expiry_micros = 0;
719        let mut payload = Vec::new();
720
721        for cell in cells {
722            // NB: All cells are written with the same timestamp; last write is safe.
723
724            // Only derive expiration from GC-family cells — manual-family cells
725            // use server-assigned timestamps that don't represent expiration.
726            if cell.family_name == FAMILY_GC {
727                expiry_micros = cell.timestamp_micros;
728                expire_at = Some(Timestamp::from_unix_micros(expiry_micros).context(
729                    ErrorKind::CorruptData,
730                    "decoding Bigtable expiration timestamp",
731                )?);
732            }
733
734            match cell.qualifier.as_slice() {
735                COLUMN_REDIRECT => {
736                    redirect_detected = true;
737                    redirect_target = cell.value;
738                }
739                COLUMN_PAYLOAD => {
740                    payload = cell.value;
741                }
742                COLUMN_METADATA => {
743                    if let Ok(legacy_meta) =
744                        serde_json::from_slice::<LegacyTombstoneMeta>(&cell.value)
745                        && legacy_meta.is_redirect_tombstone
746                    {
747                        redirect_detected = true;
748                        objectstore_metrics::count!("bigtable.legacy_tombstone_read");
749                    } else {
750                        metadata_opt = Some(serde_json::from_slice(&cell.value).context(
751                            ErrorKind::CorruptData,
752                            "decoding Bigtable object metadata",
753                        )?);
754                    }
755                }
756                _ => {}
757            }
758        }
759
760        Ok(if redirect_detected {
761            RowData::Tombstone {
762                target: redirect_target,
763                time_expires: expire_at,
764                expiry_micros,
765            }
766        } else {
767            // Metadata may have been skipped by a payload-only internal read.
768            let mut metadata = metadata_opt.unwrap_or_default();
769            metadata.time_expires = expire_at;
770            RowData::Object {
771                metadata,
772                payload,
773                expiry_micros,
774            }
775        })
776    }
777
778    /// Returns the resolved expiration timestamp for this row, regardless of variant.
779    fn time_expires(&self) -> Option<Timestamp> {
780        match self {
781            RowData::Object { metadata, .. } => metadata.time_expires,
782            RowData::Tombstone { time_expires, .. } => *time_expires,
783        }
784    }
785
786    /// Returns `true` if this row is expired as of the given `time`.
787    ///
788    /// Only applies to rows with an expiration deadline.
789    fn expires_before(&self, time: Timestamp) -> bool {
790        self.time_expires().is_some_and(|ts| ts < time)
791    }
792}
793
794/// Parses the raw `r` column bytes into a redirect target [`ObjectId`].
795///
796/// For tombstones with an empty `r` value, falls back to the ID of the tombstone
797/// itself and emits a `bigtable.empty_redirect_read` metric so deployments can
798/// track when it is safe to remove the legacy empty-value code path.
799fn parse_redirect_target(redirect_path: &[u8], tombstone_id: &ObjectId) -> Result<ObjectId> {
800    if redirect_path.is_empty() {
801        objectstore_metrics::count!("bigtable.empty_redirect_read");
802        Ok(tombstone_id.clone())
803    } else {
804        let redirect_str = std::str::from_utf8(redirect_path)
805            .context(ErrorKind::CorruptData, "decoding Bigtable redirect target")?;
806        ObjectId::from_storage_path(redirect_str)
807            .ok_or_else(|| Error::new(ErrorKind::CorruptData, "parsing Bigtable redirect target"))
808    }
809}
810
811impl BigTableBackend {
812    /// Creates a new [`BigTableBackend`] from the given `config`.
813    ///
814    /// Pass an `endpoint` in the config to connect to a local emulator; omit it to use real GCP
815    /// credentials. `connections` controls the gRPC connection pool size (defaults to 1).
816    pub async fn new(
817        config: BigTableConfig,
818        streams: &ChangeStreamFactory,
819    ) -> anyhow::Result<Self> {
820        let BigTableConfig {
821            endpoint,
822            project_id,
823            instance_name,
824            table_name,
825            connections,
826            rpc_timeout,
827            cogs,
828        } = config;
829        let change_stream = streams.build(cogs.as_ref());
830
831        let bigtable = if let Some(ref endpoint) = endpoint {
832            BigTableConnection::new_with_emulator(
833                endpoint,
834                &project_id,
835                &instance_name,
836                false, // is_read_only
837                Some(rpc_timeout),
838            )?
839        } else {
840            let token_provider = PrefetchingTokenProvider::gcp_auth(TOKEN_SCOPES).await?;
841            BigTableConnection::new_with_managed_transport(
842                &project_id,
843                &instance_name,
844                false, // is_read_only
845                Some(rpc_timeout),
846                Arc::new(token_provider),
847                connections.unwrap_or(1),
848                true, // prime_channels
849                None, // app_profile_id
850                MAX_CHANNEL_AGE,
851            )
852            .await?
853        };
854
855        let client = bigtable.client();
856
857        Ok(Self {
858            bigtable,
859            instance_path: format!("projects/{project_id}/instances/{instance_name}"),
860            table_path: client.get_full_table_name(&table_name),
861            table_name,
862            change_stream,
863        })
864    }
865
866    /// Reads a single row by key, returning parsed row data.
867    ///
868    /// Returns `None` if the row is absent or has expired.
869    #[tracing::instrument(level = "debug", fields(action), skip_all)]
870    async fn read_row(
871        &self,
872        path: &[u8],
873        action: &'static str,
874        access_time: Timestamp,
875        filter: Option<v2::RowFilter>,
876    ) -> Result<Option<RowData>> {
877        let request = v2::ReadRowsRequest {
878            table_name: self.table_path.clone(),
879            rows: Some(v2::RowSet {
880                row_keys: vec![path.to_owned()],
881                row_ranges: vec![],
882            }),
883            filter,
884            rows_limit: 1,
885            ..Default::default()
886        };
887
888        let response = retry(action, || async {
889            self.bigtable.client().read_rows(request.clone()).await
890        })
891        .await?;
892        debug_assert!(response.len() <= 1, "Expected at most one row");
893
894        let Some((_, cells)) = response.into_iter().next() else {
895            objectstore_log::debug!("Object not found");
896            return Ok(None);
897        };
898
899        let row = RowData::from_cells(cells)?;
900        Ok(if row.expires_before(access_time) {
901            None
902        } else {
903            Some(row)
904        })
905    }
906
907    #[tracing::instrument(level = "debug", fields(action), skip_all)]
908    async fn mutate(
909        &self,
910        path: Vec<u8>,
911        mutations: impl Into<Vec<v2::Mutation>>,
912        action: &'static str,
913    ) -> Result<v2::MutateRowResponse> {
914        let request = v2::MutateRowRequest {
915            table_name: self.table_path.clone(),
916            row_key: path,
917            mutations: mutations.into(),
918            ..Default::default()
919        };
920
921        let response = retry(action, || async {
922            self.bigtable.client().mutate_row(request.clone()).await
923        })
924        .await?;
925
926        Ok(response.into_inner())
927    }
928
929    /// Writes an object row, returning the size of the row it wrote.
930    async fn put_row(
931        &self,
932        path: Vec<u8>,
933        metadata: Metadata,
934        payload: Vec<u8>,
935        action: &'static str,
936    ) -> Result<(v2::MutateRowResponse, u64)> {
937        let (mutations, size) = object_mutations(&path, metadata, payload)?;
938        let response = self.mutate(path, mutations, action).await?;
939        Ok((response, size))
940    }
941
942    /// Executes a `CheckAndMutateRow` request.
943    #[tracing::instrument(level = "debug", fields(action = context), skip_all)]
944    async fn check_and_mutate(
945        &self,
946        row_key: Vec<u8>,
947        predicate: MutatePredicate,
948        mutations: impl Into<Vec<v2::Mutation>>,
949        context: &'static str,
950    ) -> Result<bool> {
951        let (filter, true_mutations, false_mutations, success_on_match) = match predicate {
952            MutatePredicate::Include(f) => (f, mutations.into(), vec![], true),
953            MutatePredicate::Exclude(f) => (f, vec![], mutations.into(), false),
954        };
955
956        let request = v2::CheckAndMutateRowRequest {
957            table_name: self.table_path.clone(),
958            row_key,
959            predicate_filter: Some(filter),
960            true_mutations,
961            false_mutations,
962            ..Default::default()
963        };
964
965        let future = retry(context, || async {
966            self.bigtable
967                .client()
968                .check_and_mutate_row(request.clone())
969                .await
970        });
971
972        Ok(future.await?.predicate_matched == success_on_match)
973    }
974}
975
976#[async_trait::async_trait]
977impl Backend for BigTableBackend {
978    fn name(&self) -> &'static str {
979        "bigtable"
980    }
981
982    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
983    async fn put_object(
984        &self,
985        id: &ObjectId,
986        metadata: &Metadata,
987        mut stream: ClientStream,
988        _access_time: Timestamp,
989    ) -> Result<PutResponse> {
990        objectstore_log::debug!("Writing to Bigtable backend");
991        let path = id.as_storage_path().to_string().into_bytes();
992
993        let mut payload = ChunkedBytes::new(0);
994        while let Some(chunk) = stream.try_next().await? {
995            payload.push(chunk);
996        }
997
998        let (_, size) = self
999            .put_row(path, metadata.clone(), payload.into_bytes().into(), "put")
1000            .await?;
1001        self.change_stream.write(id, size, metadata.time_expires);
1002
1003        Ok(())
1004    }
1005
1006    #[tracing::instrument(level = "debug", skip(self))]
1007    async fn get_object(
1008        &self,
1009        id: &ObjectId,
1010        access_time: Timestamp,
1011        range: Option<ByteRange>,
1012    ) -> Result<GetResponse> {
1013        match self.get_tiered_object(id, access_time, range).await? {
1014            TieredGet::Object(metadata, content_range, payload) => {
1015                Ok(Some((metadata, content_range, payload)))
1016            }
1017            TieredGet::Tombstone(_) => Err(ErrorKind::UnexpectedTombstone.into()),
1018            TieredGet::NotFound => Ok(None),
1019        }
1020    }
1021
1022    #[tracing::instrument(level = "debug", skip(self))]
1023    async fn get_metadata(
1024        &self,
1025        id: &ObjectId,
1026        access_time: Timestamp,
1027    ) -> Result<MetadataResponse> {
1028        match self.get_tiered_metadata(id, access_time).await? {
1029            TieredMetadata::Object(metadata) => Ok(Some(metadata)),
1030            TieredMetadata::Tombstone(_) => Err(ErrorKind::UnexpectedTombstone.into()),
1031            TieredMetadata::NotFound => Ok(None),
1032        }
1033    }
1034
1035    async fn set_expiry(
1036        &self,
1037        id: &ObjectId,
1038        expire_at: Timestamp,
1039        access_time: Timestamp,
1040    ) -> Result<bool> {
1041        self.compare_and_update(id, None, TieredUpdate::SetExpiry(expire_at), access_time)
1042            .await
1043    }
1044
1045    #[tracing::instrument(level = "debug", skip(self))]
1046    async fn delete_object(
1047        &self,
1048        id: &ObjectId,
1049        _access_time: Timestamp,
1050    ) -> Result<DeleteResponse> {
1051        objectstore_log::debug!("Deleting from Bigtable backend");
1052
1053        let path = id.as_storage_path().to_string().into_bytes();
1054        self.mutate(path, [delete_row_mutation()], "delete").await?;
1055        self.change_stream.delete(id);
1056
1057        Ok(())
1058    }
1059
1060    async fn join(&self) {
1061        flush_change_stream(&self.change_stream).await;
1062    }
1063}
1064
1065#[async_trait::async_trait]
1066impl HighVolumeBackend for BigTableBackend {
1067    #[tracing::instrument(level = "debug", fields(?id), skip_all)]
1068    async fn put_non_tombstone(
1069        &self,
1070        id: &ObjectId,
1071        metadata: &Metadata,
1072        payload: Bytes,
1073        access_time: Timestamp,
1074    ) -> Result<Option<Tombstone>> {
1075        objectstore_log::debug!("Conditional put to Bigtable backend");
1076
1077        let path = id.as_storage_path().to_string().into_bytes();
1078        let (mutations, size) = object_mutations(&path, metadata.clone(), payload.to_vec())?;
1079
1080        for _ in 0..CAS_RETRY_COUNT {
1081            let write_succeeded = self
1082                .check_and_mutate(
1083                    path.clone(),
1084                    tombstone_predicate(access_time),
1085                    mutations.clone(),
1086                    "put_non_tombstone",
1087                )
1088                .await?;
1089
1090            if write_succeeded {
1091                self.change_stream.write(id, size, metadata.time_expires);
1092                return Ok(None);
1093            }
1094
1095            // A tombstone was present: read its data for the caller.
1096            let row = self
1097                .read_row(
1098                    &path,
1099                    "put_non_tombstone",
1100                    access_time,
1101                    Some(metadata_filter()),
1102                )
1103                .await?;
1104
1105            match row {
1106                Some(RowData::Tombstone {
1107                    target,
1108                    time_expires,
1109                    ..
1110                }) => {
1111                    return Ok(Some(Tombstone {
1112                        target: parse_redirect_target(&target, id)?,
1113                        time_expires,
1114                    }));
1115                }
1116                // Race: Tombstone was replaced by an object, retry to overwrite
1117                Some(RowData::Object { .. }) => continue,
1118                // Race: Tombstone was deleted, retry to write.
1119                None => continue,
1120            }
1121        }
1122
1123        Err(Error::new(
1124            ErrorKind::Internal,
1125            "Bigtable put race exhausted",
1126        ))
1127    }
1128
1129    #[tracing::instrument(level = "debug", skip(self))]
1130    async fn get_tiered_object(
1131        &self,
1132        id: &ObjectId,
1133        access_time: Timestamp,
1134        range: Option<ByteRange>,
1135    ) -> Result<TieredGet> {
1136        objectstore_log::debug!("Reading from Bigtable backend");
1137        let path = id.as_storage_path().to_string().into_bytes();
1138
1139        let Some(row) = self
1140            .read_row(&path, "get_tiered_object", access_time, None)
1141            .await?
1142        else {
1143            return Ok(TieredGet::NotFound);
1144        };
1145
1146        Ok(match row {
1147            RowData::Tombstone {
1148                target,
1149                time_expires,
1150                ..
1151            } => TieredGet::Tombstone(Tombstone {
1152                target: parse_redirect_target(&target, id)?,
1153                time_expires,
1154            }),
1155            RowData::Object {
1156                metadata, payload, ..
1157            } => {
1158                let mut metadata = metadata;
1159                let payload = Bytes::from(payload);
1160                if metadata.size.is_none() {
1161                    // If object size wasn't written into the metadata, re-compute it now
1162                    metadata.size = Some(payload.len());
1163                }
1164
1165                let (content_range, payload) = apply_range(payload, range)?;
1166                TieredGet::Object(metadata, content_range, crate::stream::single(payload))
1167            }
1168        })
1169    }
1170
1171    #[tracing::instrument(level = "debug", skip(self))]
1172    async fn get_tiered_metadata(
1173        &self,
1174        id: &ObjectId,
1175        access_time: Timestamp,
1176    ) -> Result<TieredMetadata> {
1177        objectstore_log::debug!("Reading metadata from Bigtable backend");
1178        let path = id.as_storage_path().to_string().into_bytes();
1179
1180        // Read metadata and tombstone columns — skip the (potentially large) payload.
1181        // NB: `metadata.size` will only be populated if the size was added to the metadata before
1182        // writing to Bigtable.
1183        let row_opt = self
1184            .read_row(
1185                &path,
1186                "get_tiered_metadata",
1187                access_time,
1188                Some(metadata_filter()),
1189            )
1190            .await?;
1191        let Some(row) = row_opt else {
1192            return Ok(TieredMetadata::NotFound);
1193        };
1194
1195        Ok(match row {
1196            RowData::Tombstone {
1197                target,
1198                time_expires,
1199                ..
1200            } => TieredMetadata::Tombstone(Tombstone {
1201                target: parse_redirect_target(&target, id)?,
1202                time_expires,
1203            }),
1204            RowData::Object { metadata, .. } => TieredMetadata::Object(metadata),
1205        })
1206    }
1207
1208    #[tracing::instrument(level = "debug", skip(self))]
1209    async fn compare_and_update(
1210        &self,
1211        id: &ObjectId,
1212        current: Option<&ObjectId>,
1213        update: TieredUpdate,
1214        access_time: Timestamp,
1215    ) -> Result<bool> {
1216        let TieredUpdate::SetExpiry(expire_at) = update;
1217        let path = id.as_storage_path().to_string().into_bytes();
1218
1219        // Inline extension needs metadata and payload from the same read so a
1220        // successful conditional rewrite can preserve the payload verbatim.
1221        let Some(row) = self
1222            .read_row(&path, "set_expiry", access_time, None)
1223            .await?
1224        else {
1225            return Ok(false);
1226        };
1227
1228        let (predicate, mutations): (_, Vec<_>) = match row {
1229            RowData::Object {
1230                metadata,
1231                payload,
1232                expiry_micros,
1233            } => {
1234                if current.is_some() {
1235                    return Ok(false); // wrong row kind
1236                }
1237                let Some(old_expiry) = metadata.time_expires else {
1238                    return Ok(false);
1239                };
1240
1241                if old_expiry < access_time {
1242                    return Ok(false); // already expired
1243                } else if old_expiry >= expire_at {
1244                    return Ok(true); // already satisfied
1245                }
1246
1247                // Observing a live cell here is not atomic with wall-clock
1248                // expiry or Bigtable GC. The conditional write may still lose
1249                // to either and then returns false.
1250                let predicate = inline_expiry_predicate(expiry_micros, access_time)?;
1251                let mut metadata = metadata;
1252                metadata.time_expires = Some(expire_at);
1253                let (mutations, _) = object_mutations(&path, metadata, payload)?;
1254                (predicate, mutations.into())
1255            }
1256            RowData::Tombstone {
1257                target,
1258                time_expires,
1259                expiry_micros,
1260            } => {
1261                let Some(expected) = current else {
1262                    return Ok(false); // wrong row kind
1263                };
1264                let Some(old_expiry) = time_expires else {
1265                    return Ok(false);
1266                };
1267
1268                let target = parse_redirect_target(&target, id)?;
1269                if target != *expected || old_expiry < access_time {
1270                    return Ok(false); // wrong target or already expired
1271                } else if old_expiry >= expire_at {
1272                    return Ok(true); // already satisfied
1273                }
1274
1275                let predicate =
1276                    redirect_expiry_predicate(expected, id, expiry_micros, access_time)?;
1277                let tombstone = Tombstone {
1278                    target,
1279                    time_expires: Some(expire_at),
1280                };
1281                (predicate, tombstone_mutations(&tombstone).into())
1282            }
1283        };
1284
1285        let applied = self
1286            .check_and_mutate(path, predicate, mutations, "set_expiry")
1287            .await?;
1288
1289        if applied {
1290            self.change_stream.update(id, Some(expire_at));
1291        }
1292
1293        Ok(applied)
1294    }
1295
1296    #[tracing::instrument(level = "debug", skip(self))]
1297    async fn delete_non_tombstone(
1298        &self,
1299        id: &ObjectId,
1300        access_time: Timestamp,
1301    ) -> Result<Option<Tombstone>> {
1302        objectstore_log::debug!("Conditional delete from Bigtable backend");
1303
1304        let path = id.as_storage_path().to_string().into_bytes();
1305
1306        for _ in 0..CAS_RETRY_COUNT {
1307            let deleted = self
1308                .check_and_mutate(
1309                    path.clone(),
1310                    non_tombstone_predicate(access_time),
1311                    [delete_row_mutation()],
1312                    "delete_non_tombstone",
1313                )
1314                .await?;
1315
1316            if deleted {
1317                self.change_stream.delete(id);
1318                return Ok(None);
1319            }
1320
1321            // Nothing was deleted: either a tombstone is in the way, or the row is absent.
1322            // Read the row to find out which, and to hand the tombstone to the caller.
1323            let row = self
1324                .read_row(
1325                    &path,
1326                    "delete_non_tombstone",
1327                    access_time,
1328                    Some(metadata_filter()),
1329                )
1330                .await?;
1331
1332            match row {
1333                Some(RowData::Tombstone {
1334                    target,
1335                    time_expires,
1336                    ..
1337                }) => {
1338                    return Ok(Some(Tombstone {
1339                        target: parse_redirect_target(&target, id)?,
1340                        time_expires,
1341                    }));
1342                }
1343                // Race: An object appeared since the predicate ran, delete the new object now.
1344                Some(RowData::Object { .. }) => continue,
1345                // The row is absent or expired, nothing left to do.
1346                None => return Ok(None),
1347            }
1348        }
1349
1350        Err(Error::new(
1351            ErrorKind::Internal,
1352            "Bigtable delete race exhausted",
1353        ))
1354    }
1355
1356    #[tracing::instrument(level = "debug", skip(self, write))]
1357    async fn compare_and_write(
1358        &self,
1359        id: &ObjectId,
1360        current: Option<&ObjectId>,
1361        write: TieredWrite,
1362        access_time: Timestamp,
1363    ) -> Result<bool> {
1364        objectstore_log::debug!("CAS put to Bigtable backend");
1365
1366        let path = id.as_storage_path().to_string().into_bytes();
1367        let predicate = match (current, write.target()) {
1368            (Some(old), Some(new)) => update_predicate(old, new, id, access_time),
1369            (Some(target), None) => optional_target_predicate(target, id, access_time),
1370            (None, Some(target)) => optional_target_predicate(target, id, access_time),
1371            (None, None) => tombstone_predicate(access_time),
1372        };
1373
1374        // Get the correct set of mutations to apply as well as the new expiration date.
1375        // If we're deleting something, `expires_at` is `None`. If we're writing something
1376        // without an expiration date, `expires_at` is `Some(None)`.
1377        let (mutations, expires_at): (Vec<v2::Mutation>, Option<Option<Timestamp>>) = match write {
1378            TieredWrite::Tombstone(tombstone) => (
1379                tombstone_mutations(&tombstone).into(),
1380                Some(tombstone.time_expires),
1381            ),
1382            TieredWrite::Object(m, p) => {
1383                let expires_at = m.time_expires;
1384                let (mutations, _) = object_mutations(&path, m, p.to_vec())?;
1385                (mutations.into(), Some(expires_at))
1386            }
1387            TieredWrite::Delete => (vec![delete_row_mutation()], None),
1388        };
1389
1390        let written = self
1391            .check_and_mutate(
1392                path.clone(),
1393                predicate,
1394                mutations.clone(),
1395                "compare_and_write",
1396            )
1397            .await?;
1398
1399        match (written, expires_at) {
1400            // Don't record anything if the write didn't succeed
1401            (false, _) => {}
1402            // We wrote something (the inner `expires_at` is `None` for manual GC)
1403            (true, Some(expires_at)) => {
1404                self.change_stream
1405                    .write(id, row_size(&path, &mutations), expires_at)
1406            }
1407            // We deleted something
1408            (true, None) => self.change_stream.delete(id),
1409        }
1410
1411        Ok(written)
1412    }
1413}
1414
1415/// Retries a BigTable RPC on transient errors.
1416async fn retry<T, F>(context: &'static str, f: impl Fn() -> F) -> Result<T>
1417where
1418    F: Future<Output = Result<T, BigTableError>> + Send,
1419{
1420    let mut retry_count = 0usize;
1421
1422    loop {
1423        let attempt_span = tracing::debug_span!(
1424            "bigtable.request",
1425            action = context,
1426            grpc.status = tracing::field::Empty,
1427        );
1428        let attempt = async {
1429            let result = f().await;
1430            let span = tracing::Span::current();
1431            match &result {
1432                Ok(_) => span.record("grpc.status", "ok"),
1433                Err(BigTableError::RpcError(status)) => {
1434                    span.record("grpc.status", tracing::field::debug(status.code()))
1435                }
1436                // Non-RPC error; the error event carries the details.
1437                Err(_) => &span,
1438            };
1439            result
1440        };
1441
1442        match attempt.instrument(attempt_span).await {
1443            Ok(res) => return Ok(res),
1444            Err(e) if retry_count >= REQUEST_RETRY_COUNT || !is_retryable(&e) => {
1445                objectstore_metrics::count!("bigtable.failures", action = context);
1446                return Err(e).context(
1447                    ErrorKind::BackendFailure,
1448                    format!("running Bigtable {context}"),
1449                );
1450            }
1451            Err(e) => {
1452                retry_count += 1;
1453                objectstore_metrics::count!("bigtable.retries", action = context);
1454                objectstore_log::warn!(!!&e, retry_count, context, "Retrying request");
1455            }
1456        }
1457    }
1458}
1459
1460fn is_retryable(error: &BigTableError) -> bool {
1461    match error {
1462        // Transient errors on auth token refresh
1463        BigTableError::GCPAuthError(_) => true,
1464        // Transient GRPC network failures
1465        BigTableError::TransportError(_) => true,
1466        // These could also indicate transient network failures
1467        BigTableError::IoError(_) => true,
1468        BigTableError::TimeoutError(_) => true,
1469
1470        // See https://docs.cloud.google.com/bigtable/docs/status-codes
1471        BigTableError::RpcError(status) => match status.code() {
1472            // Generic retriable status
1473            Code::Unavailable => true,
1474            // Timeouts
1475            Code::Cancelled => true,
1476            Code::DeadlineExceeded => true,
1477            // Token might have refreshed too late
1478            Code::Unauthenticated => true,
1479            // Unspecified, attempt to retry anyways
1480            Code::Aborted => true,
1481            Code::Internal => true,
1482            Code::FailedPrecondition => true,
1483            Code::Unknown => true,
1484            _ => false,
1485        },
1486        _ => false,
1487    }
1488}
1489
1490/// Resolves an optional byte range against a payload buffer, returning the
1491/// applicable content range and the (potentially narrowed) payload.
1492///
1493/// When `range` is `None`, returns the full payload unchanged. Uses
1494/// `Bytes::slice` to avoid copying data.
1495fn apply_range(payload: Bytes, range: Option<ByteRange>) -> Result<(Option<ContentRange>, Bytes)> {
1496    let Some(byte_range) = range else {
1497        return Ok((None, payload));
1498    };
1499
1500    let total = payload.len() as u64;
1501    let content_range = byte_range
1502        .resolve(total)
1503        .ok_or(ErrorKind::RangeNotSatisfiable { total })?;
1504
1505    let sliced = payload.slice(content_range.start as usize..content_range.end as usize + 1);
1506    Ok((Some(content_range), sliced))
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511    use std::collections::BTreeMap;
1512
1513    use anyhow::Result;
1514    #[cfg(feature = "storage-cogs")]
1515    use objectstore_inventory_tracker::OpType;
1516    #[cfg(feature = "storage-cogs")]
1517    use objectstore_inventory_tracker::test_utils::DummyProducer;
1518
1519    use objectstore_types::metadata::ExpirationPolicy;
1520    use objectstore_types::scope::{Scope, Scopes};
1521
1522    use super::*;
1523    use crate::id::ObjectContext;
1524    use crate::stream;
1525
1526    // NB: Most of these tests require a BigTable emulator running. This is done
1527    // automatically in CI.
1528    //
1529    // Refer to the readme for how to set up the emulator.
1530
1531    fn test_config() -> BigTableConfig {
1532        BigTableConfig {
1533            endpoint: Some("localhost:8086".into()),
1534            project_id: "testing".into(),
1535            instance_name: "objectstore".into(),
1536            table_name: "objectstore".into(),
1537            connections: None,
1538            rpc_timeout: default_rpc_timeout(),
1539            cogs: None,
1540        }
1541    }
1542
1543    async fn create_test_backend() -> Result<BigTableBackend> {
1544        BigTableBackend::new(test_config(), &ChangeStreamFactory::default()).await
1545    }
1546
1547    #[cfg(feature = "storage-cogs")]
1548    async fn create_test_backend_with_change_stream() -> Result<(BigTableBackend, DummyProducer)> {
1549        let (streams, producer) = crate::change_stream::dummy_factory();
1550        let config = BigTableConfig {
1551            cogs: Some(CostTrackerStreamConfig {
1552                shared_resource_id: "bigtable_objectstore".into(),
1553                sample_rate: 1.0,
1554            }),
1555            ..test_config()
1556        };
1557
1558        Ok((BigTableBackend::new(config, &streams).await?, producer))
1559    }
1560
1561    fn make_id() -> ObjectId {
1562        ObjectId::random(ObjectContext {
1563            usecase: "testing".into(),
1564            scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
1565        })
1566    }
1567
1568    async fn create_object(
1569        backend: &BigTableBackend,
1570        id: &ObjectId,
1571        metadata: &Metadata,
1572        payload: &[u8],
1573        now: Timestamp,
1574    ) -> Result<()> {
1575        let path = id.as_storage_path().to_string().into_bytes();
1576        // Resolve `time_expires` from `now` (as `from_insert_headers` does) unless the test set
1577        // it explicitly, so `object_mutations` has an expiration to persist.
1578        let mut metadata = metadata.clone();
1579        if metadata.time_expires.is_none() {
1580            metadata.time_expires = metadata.expiration_policy.expires_in().map(|ttl| now + ttl);
1581        }
1582        let (mutations, _) = object_mutations(&path, metadata, payload.to_vec())?;
1583        backend.mutate(path, mutations, "test-setup").await?;
1584        Ok(())
1585    }
1586
1587    async fn create_tombstone(
1588        backend: &BigTableBackend,
1589        id: &ObjectId,
1590        tombstone: &Tombstone,
1591    ) -> Result<()> {
1592        let path = id.as_storage_path().to_string().into_bytes();
1593        let mutations = tombstone_mutations(tombstone);
1594        backend.mutate(path, mutations, "test-setup").await?;
1595        Ok(())
1596    }
1597
1598    /// Writes a legacy-format tombstone row directly into Bigtable.
1599    async fn write_legacy_tombstone(
1600        backend: &BigTableBackend,
1601        id: &ObjectId,
1602        expiration_policy: ExpirationPolicy,
1603        time_expires: Option<Timestamp>,
1604    ) -> Result<()> {
1605        let meta = if expiration_policy.is_manual() {
1606            r#"{"is_redirect_tombstone":true}"#.to_owned()
1607        } else {
1608            let policy_json = serde_json::to_string(&expiration_policy).unwrap();
1609            format!(r#"{{"is_redirect_tombstone":true,"expiration_policy":{policy_json}}}"#)
1610        };
1611
1612        let (family, timestamp_micros) = if expiration_policy.is_manual() {
1613            (FAMILY_MANUAL, -1)
1614        } else {
1615            let t =
1616                time_expires.unwrap_or(Timestamp::now() + expiration_policy.expires_in().unwrap());
1617            (FAMILY_GC, t.as_micros() as i64)
1618        };
1619
1620        let path = id.as_storage_path().to_string().into_bytes();
1621        let mutations = [mutation(mutation::Mutation::SetCell(mutation::SetCell {
1622            family_name: family.to_owned(),
1623            column_qualifier: COLUMN_METADATA.to_owned(),
1624            timestamp_micros,
1625            value: meta.into_bytes(),
1626        }))];
1627
1628        backend.mutate(path, mutations, "test-setup").await?;
1629
1630        Ok(())
1631    }
1632
1633    /// Writes a historical `r`/`t` tombstone row with an empty `r` value directly.
1634    async fn write_empty_redirect_tombstone(
1635        backend: &BigTableBackend,
1636        id: &ObjectId,
1637    ) -> Result<()> {
1638        let path = id.as_storage_path().to_string().into_bytes();
1639        let mutations = [
1640            mutation(mutation::Mutation::SetCell(mutation::SetCell {
1641                family_name: FAMILY_MANUAL.to_owned(),
1642                column_qualifier: COLUMN_REDIRECT.to_owned(),
1643                timestamp_micros: -1,
1644                value: b"".to_vec(), // empty — legacy format
1645            })),
1646            mutation(mutation::Mutation::SetCell(mutation::SetCell {
1647                family_name: FAMILY_MANUAL.to_owned(),
1648                column_qualifier: b"t".to_vec(),
1649                timestamp_micros: -1,
1650                value: b"{}".to_vec(),
1651            })),
1652        ];
1653
1654        backend.mutate(path, mutations, "test-setup").await?;
1655
1656        Ok(())
1657    }
1658
1659    // --- Section 1: Object Operations ---
1660
1661    /// Verifies the full roundtrip: put → get_object (payload + metadata) → get_metadata (metadata).
1662    #[tokio::test]
1663    async fn test_roundtrip() -> Result<()> {
1664        let backend = create_test_backend().await?;
1665
1666        let id = make_id();
1667        let metadata = Metadata {
1668            content_type: "text/plain".into(),
1669            time_created: Some(Timestamp::now()),
1670            custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1671            ..Default::default()
1672        };
1673
1674        backend
1675            .put_object(
1676                &id,
1677                &metadata,
1678                stream::single("hello, world"),
1679                Timestamp::now(),
1680            )
1681            .await?;
1682
1683        let (obj_meta, _, stream) = backend
1684            .get_object(&id, Timestamp::now(), None)
1685            .await?
1686            .unwrap();
1687        let payload = stream::read_to_vec(stream).await?;
1688        assert_eq!(payload, b"hello, world");
1689        assert_eq!(obj_meta.content_type, metadata.content_type);
1690        assert_eq!(obj_meta.custom, metadata.custom);
1691
1692        let head_meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
1693        assert_eq!(head_meta.content_type, metadata.content_type);
1694        assert_eq!(head_meta.custom, metadata.custom);
1695
1696        Ok(())
1697    }
1698
1699    /// Verifies that a server-resolved `time_expires` is persisted verbatim, not recomputed.
1700    #[tokio::test]
1701    async fn test_time_expires_roundtrip() -> Result<()> {
1702        let backend = create_test_backend().await?;
1703
1704        let id = make_id();
1705        let ttl = Duration::from_hours(2 * 24);
1706        let expires = Timestamp::now() + ttl;
1707        let metadata = Metadata {
1708            expiration_policy: ExpirationPolicy::TimeToLive(ttl),
1709            time_expires: Some(expires),
1710            ..Default::default()
1711        };
1712        create_object(&backend, &id, &metadata, b"data", Timestamp::now()).await?;
1713
1714        let meta = backend.get_metadata(&id, Timestamp::now()).await?.unwrap();
1715        assert_eq!(meta.time_expires, Some(expires));
1716
1717        Ok(())
1718    }
1719
1720    /// Verifies that absent rows return None or succeed silently for all read/delete operations.
1721    #[tokio::test]
1722    async fn test_nonexistent() -> Result<()> {
1723        let backend = create_test_backend().await?;
1724
1725        let id = make_id();
1726        assert!(
1727            backend
1728                .get_object(&id, Timestamp::now(), None)
1729                .await?
1730                .is_none()
1731        );
1732        assert!(backend.get_metadata(&id, Timestamp::now()).await?.is_none());
1733        backend.delete_object(&id, Timestamp::now()).await?;
1734
1735        Ok(())
1736    }
1737
1738    #[tokio::test]
1739    async fn test_overwrite() -> Result<()> {
1740        let backend = create_test_backend().await?;
1741
1742        let id = make_id();
1743        let first_metadata = Metadata {
1744            custom: BTreeMap::from_iter([("invalid".into(), "invalid".into())]),
1745            ..Default::default()
1746        };
1747        create_object(&backend, &id, &first_metadata, b"hello", Timestamp::now()).await?;
1748
1749        let second_metadata = Metadata {
1750            custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
1751            ..Default::default()
1752        };
1753        backend
1754            .put_object(
1755                &id,
1756                &second_metadata,
1757                stream::single("world"),
1758                Timestamp::now(),
1759            )
1760            .await?;
1761
1762        let (meta, _, stream) = backend
1763            .get_object(&id, Timestamp::now(), None)
1764            .await?
1765            .unwrap();
1766        let payload = stream::read_to_vec(stream).await?;
1767        assert_eq!(payload, b"world");
1768        assert_eq!(meta.custom, second_metadata.custom);
1769
1770        Ok(())
1771    }
1772
1773    #[tokio::test]
1774    async fn test_read_after_delete() -> Result<()> {
1775        let backend = create_test_backend().await?;
1776
1777        let id = make_id();
1778        let metadata = Metadata::default();
1779        create_object(&backend, &id, &metadata, b"hello", Timestamp::now()).await?;
1780        backend.delete_object(&id, Timestamp::now()).await?;
1781
1782        assert!(
1783            backend
1784                .get_object(&id, Timestamp::now(), None)
1785                .await?
1786                .is_none()
1787        );
1788
1789        Ok(())
1790    }
1791
1792    /// Backend reads are side-effect-free; explicit extension preserves payload.
1793    #[tokio::test]
1794    async fn test_set_expiry() -> Result<()> {
1795        let backend = create_test_backend().await?;
1796        let tti = Duration::from_hours(2 * 24);
1797        let mut metadata = Metadata {
1798            expiration_policy: ExpirationPolicy::TimeToIdle(tti),
1799            ..Default::default()
1800        };
1801
1802        // Backdate `now` so the written expiry (past_now + tti) is stale but not expired.
1803        let past_now = Timestamp::now() - tti + Duration::from_mins(1);
1804
1805        let id = make_id();
1806        metadata.time_expires = Some(past_now + tti);
1807        let path = id.as_storage_path().to_string().into_bytes();
1808        let (mutations, _) = object_mutations(&path, metadata, b"hello, world".to_vec())?;
1809        // Simulate a legacy fractional deadline. Renewal must match the raw GC timestamp.
1810        let mutations = mutations.map(|mut mutation| {
1811            if let Some(mutation::Mutation::SetCell(cell)) = &mut mutation.mutation {
1812                cell.timestamp_micros -= 500_000;
1813            }
1814            mutation
1815        });
1816        backend.mutate(path, mutations, "test-setup").await?;
1817
1818        let (observed, _, _) = backend
1819            .get_object(&id, Timestamp::now(), None)
1820            .await?
1821            .unwrap();
1822        let observed_expiry = observed.time_expires.unwrap();
1823        assert_eq!(
1824            backend
1825                .get_metadata(&id, Timestamp::now())
1826                .await?
1827                .unwrap()
1828                .time_expires,
1829            Some(observed_expiry),
1830            "backend reads must not renew TTI"
1831        );
1832
1833        let requested = Timestamp::now() + tti;
1834        assert!(backend.set_expiry(&id, requested, Timestamp::now()).await?);
1835        assert_eq!(
1836            backend
1837                .get_metadata(&id, Timestamp::now())
1838                .await?
1839                .unwrap()
1840                .time_expires,
1841            Some(requested)
1842        );
1843        let (_, _, stream) = backend
1844            .get_object(&id, Timestamp::now(), None)
1845            .await?
1846            .unwrap();
1847        let payload = stream::read_to_vec(stream).await?;
1848        assert_eq!(payload, b"hello, world");
1849
1850        Ok(())
1851    }
1852
1853    #[tokio::test]
1854    async fn test_expiry_conflict() -> Result<()> {
1855        let backend = create_test_backend().await?;
1856        let missing = make_id();
1857        assert!(
1858            !backend
1859                .set_expiry(
1860                    &missing,
1861                    Timestamp::now() + Duration::from_hours(2),
1862                    Timestamp::now()
1863                )
1864                .await?
1865        );
1866
1867        let id = make_id();
1868        let observed_expiry = Timestamp::now() + Duration::from_hours(1);
1869        let original = Metadata {
1870            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_hours(1)),
1871            time_expires: Some(observed_expiry),
1872            ..Default::default()
1873        };
1874        create_object(&backend, &id, &original, b"original", Timestamp::now()).await?;
1875
1876        let path = id.as_storage_path().to_string().into_bytes();
1877        let mut extended = original.clone();
1878        extended.time_expires = Some(observed_expiry + Duration::from_hours(1));
1879        let (extension, _) = object_mutations(&path, extended, b"original".to_vec())?;
1880        let predicate =
1881            inline_expiry_predicate(observed_expiry.as_micros() as i64, Timestamp::now())?;
1882
1883        let mut replacement = original.clone();
1884        replacement.time_expires = Some(observed_expiry + Duration::from_mins(1));
1885        create_object(
1886            &backend,
1887            &id,
1888            &replacement,
1889            b"replacement",
1890            Timestamp::now(),
1891        )
1892        .await?;
1893        assert!(
1894            !backend
1895                .check_and_mutate(path, predicate, extension, "test-expiry-conflict")
1896                .await?
1897        );
1898        let (_, _, payload) = backend
1899            .get_object(&id, Timestamp::now(), None)
1900            .await?
1901            .unwrap();
1902        assert_eq!(stream::read_to_vec(payload).await?, b"replacement");
1903        Ok(())
1904    }
1905
1906    #[tokio::test]
1907    async fn test_redirect_expiry() -> Result<()> {
1908        let backend = create_test_backend().await?;
1909        let id = make_id();
1910        let target = ObjectId::random(id.context().clone());
1911        let wrong_target = ObjectId::random(id.context().clone());
1912        let old_expiry = Timestamp::now() + Duration::from_hours(1);
1913        let path = id.as_storage_path().to_string().into_bytes();
1914        let mutations = tombstone_mutations(&Tombstone {
1915            target: target.clone(),
1916            time_expires: Some(old_expiry),
1917        })
1918        .map(|mut mutation| {
1919            if let Some(mutation::Mutation::SetCell(cell)) = &mut mutation.mutation {
1920                cell.timestamp_micros -= 500_000;
1921            }
1922            mutation
1923        });
1924        backend.mutate(path, mutations, "test-setup").await?;
1925
1926        let later = old_expiry + Duration::from_hours(2);
1927        assert!(
1928            !backend
1929                .compare_and_update(
1930                    &id,
1931                    Some(&wrong_target),
1932                    TieredUpdate::SetExpiry(later),
1933                    Timestamp::now(),
1934                )
1935                .await?
1936        );
1937        assert!(
1938            backend
1939                .compare_and_update(
1940                    &id,
1941                    Some(&target),
1942                    TieredUpdate::SetExpiry(later),
1943                    Timestamp::now()
1944                )
1945                .await?
1946        );
1947        assert!(
1948            backend
1949                .compare_and_update(
1950                    &id,
1951                    Some(&target),
1952                    TieredUpdate::SetExpiry(old_expiry + Duration::from_mins(30)),
1953                    Timestamp::now(),
1954                )
1955                .await?
1956        );
1957        let TieredMetadata::Tombstone(tombstone) =
1958            backend.get_tiered_metadata(&id, Timestamp::now()).await?
1959        else {
1960            panic!("expected tombstone");
1961        };
1962        assert_eq!(tombstone.time_expires, Some(later));
1963        Ok(())
1964    }
1965
1966    // --- Section 2: Expiration ---
1967
1968    #[tokio::test]
1969    async fn test_ttl_immediate() -> Result<()> {
1970        // NB: We create a TTL that immediately expires in this test. This might be optimized away
1971        // in a future implementation, so we will have to update this test accordingly.
1972
1973        let backend = create_test_backend().await?;
1974
1975        let id = make_id();
1976        let metadata = Metadata {
1977            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
1978            time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
1979            ..Default::default()
1980        };
1981        create_object(&backend, &id, &metadata, b"hello, world", Timestamp::now()).await?;
1982
1983        assert!(
1984            backend
1985                .get_object(&id, Timestamp::now(), None)
1986                .await?
1987                .is_none()
1988        );
1989
1990        Ok(())
1991    }
1992
1993    #[tokio::test]
1994    async fn test_tti_immediate() -> Result<()> {
1995        // NB: We create a TTI that immediately expires in this test. This might be optimized away
1996        // in a future implementation, so we will have to update this test accordingly.
1997
1998        let backend = create_test_backend().await?;
1999
2000        let id = make_id();
2001        let metadata = Metadata {
2002            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(0)),
2003            time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2004            ..Default::default()
2005        };
2006        create_object(&backend, &id, &metadata, b"hello, world", Timestamp::now()).await?;
2007
2008        assert!(
2009            backend
2010                .get_object(&id, Timestamp::now(), None)
2011                .await?
2012                .is_none()
2013        );
2014
2015        Ok(())
2016    }
2017
2018    // --- Section 3: Tiered Operations ---
2019
2020    /// Covers all three row states for `get_tiered_object` and `get_tiered_metadata`.
2021    ///
2022    /// - **empty**: both return NotFound.
2023    /// - **object**: put_object, both return the Object variant with correct payload/metadata.
2024    /// - **tombstone**: CAS-write with a distinct `lt_id`, both return the Tombstone variant
2025    ///   with `target == lt_id`.
2026    #[tokio::test]
2027    async fn test_tiered_get() -> Result<()> {
2028        let backend = create_test_backend().await?;
2029
2030        // empty
2031        let id = make_id();
2032        assert!(matches!(
2033            backend
2034                .get_tiered_object(&id, Timestamp::now(), None)
2035                .await?,
2036            TieredGet::NotFound
2037        ));
2038        assert!(matches!(
2039            backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2040            TieredMetadata::NotFound
2041        ));
2042
2043        // object
2044        let id = make_id();
2045        let put_meta = Metadata {
2046            content_type: "text/plain".into(),
2047            custom: BTreeMap::from_iter([("k".into(), "v".into())]),
2048            ..Default::default()
2049        };
2050        create_object(&backend, &id, &put_meta, b"payload", Timestamp::now()).await?;
2051
2052        let TieredGet::Object(obj_meta, _, obj_stream) = backend
2053            .get_tiered_object(&id, Timestamp::now(), None)
2054            .await?
2055        else {
2056            panic!("expected TieredGet::Object");
2057        };
2058        let obj_payload = stream::read_to_vec(obj_stream).await?;
2059        assert_eq!(obj_payload, b"payload");
2060        assert_eq!(obj_meta.content_type, put_meta.content_type);
2061        assert_eq!(obj_meta.custom, put_meta.custom);
2062
2063        let TieredMetadata::Object(head_meta) =
2064            backend.get_tiered_metadata(&id, Timestamp::now()).await?
2065        else {
2066            panic!("expected TieredMetadata::Object");
2067        };
2068        assert_eq!(head_meta.content_type, put_meta.content_type);
2069        assert_eq!(head_meta.custom, put_meta.custom);
2070
2071        // tombstone
2072        let hv_id = make_id();
2073        let lt_id = ObjectId::random(hv_id.context().clone());
2074        let tombstone = Tombstone {
2075            target: lt_id.clone(),
2076            time_expires: None,
2077        };
2078        create_tombstone(&backend, &hv_id, &tombstone).await?;
2079
2080        match backend
2081            .get_tiered_object(&hv_id, Timestamp::now(), None)
2082            .await?
2083        {
2084            TieredGet::Tombstone(get_t) => assert_eq!(get_t.target, lt_id),
2085            other => panic!("expected TieredGet::Tombstone, got {other:?}"),
2086        }
2087        match backend
2088            .get_tiered_metadata(&hv_id, Timestamp::now())
2089            .await?
2090        {
2091            TieredMetadata::Tombstone(meta_t) => assert_eq!(meta_t.target, lt_id,),
2092            other => panic!("expected TieredMetadata::Tombstone, got {other:?}"),
2093        }
2094
2095        Ok(())
2096    }
2097
2098    /// Covers all three row states for `put_non_tombstone`.
2099    ///
2100    /// - **empty**: returns None, object is readable.
2101    /// - **object**: overwrites with new payload, returns None.
2102    /// - **tombstone**: returns Some(Tombstone) with the correct target; tombstone still intact.
2103    #[tokio::test]
2104    async fn test_put_non_tombstone() -> Result<()> {
2105        let backend = create_test_backend().await?;
2106
2107        // empty: put_non_tombstone on absent row succeeds and makes object readable.
2108        let id = make_id();
2109        let metadata = Metadata::default();
2110        let result = backend
2111            .put_non_tombstone(
2112                &id,
2113                &metadata,
2114                Bytes::from_static(b"first"),
2115                Timestamp::now(),
2116            )
2117            .await?;
2118        assert_eq!(result, None, "expected None on empty row");
2119        let (_, _, stream) = backend
2120            .get_object(&id, Timestamp::now(), None)
2121            .await?
2122            .unwrap();
2123        assert_eq!(&stream::read_to_vec(stream).await?, b"first");
2124
2125        // object: put_non_tombstone on existing object replaces payload, returns None.
2126        let id = make_id();
2127        create_object(&backend, &id, &metadata, b"old", Timestamp::now()).await?;
2128        let result = backend
2129            .put_non_tombstone(&id, &metadata, Bytes::from_static(b"new"), Timestamp::now())
2130            .await?;
2131        assert_eq!(result, None, "expected None when overwriting object");
2132        let (_, _, stream) = backend
2133            .get_object(&id, Timestamp::now(), None)
2134            .await?
2135            .unwrap();
2136        assert_eq!(&stream::read_to_vec(stream).await?, b"new");
2137
2138        // tombstone: put_non_tombstone returns Some(Tombstone) and leaves tombstone intact.
2139        let hv_id = make_id();
2140        let lt_id = ObjectId::random(hv_id.context().clone());
2141        let tombstone = Tombstone {
2142            target: lt_id.clone(),
2143            time_expires: None,
2144        };
2145        create_tombstone(&backend, &hv_id, &tombstone).await?;
2146        let result = backend
2147            .put_non_tombstone(&hv_id, &metadata, Bytes::new(), Timestamp::now())
2148            .await?;
2149        let returned = result.expect("expected Some(Tombstone) when row is a tombstone");
2150        assert_eq!(returned.target, lt_id);
2151        assert!(
2152            matches!(
2153                backend
2154                    .get_tiered_metadata(&hv_id, Timestamp::now())
2155                    .await?,
2156                TieredMetadata::Tombstone(_)
2157            ),
2158            "tombstone must still exist after put_non_tombstone"
2159        );
2160
2161        Ok(())
2162    }
2163
2164    /// Covers all three row states for `delete_non_tombstone`.
2165    ///
2166    /// - **empty**: returns None.
2167    /// - **object**: returns None, row gone.
2168    /// - **tombstone**: returns Some(Tombstone) with correct target; tombstone still intact.
2169    ///
2170    /// Verifies that the `r` column is correctly detected by both the `ReadRows` column
2171    /// filter and the `CheckAndMutate` `non_tombstone_predicate`.
2172    #[tokio::test]
2173    async fn test_delete_non_tombstone() -> Result<()> {
2174        let backend = create_test_backend().await?;
2175
2176        // empty
2177        let id = make_id();
2178        assert_eq!(
2179            backend.delete_non_tombstone(&id, Timestamp::now()).await?,
2180            None
2181        );
2182
2183        // object
2184        let id = make_id();
2185        let metadata = Metadata::default();
2186        create_object(&backend, &id, &metadata, b"hello, world", Timestamp::now()).await?;
2187        assert_eq!(
2188            backend.delete_non_tombstone(&id, Timestamp::now()).await?,
2189            None
2190        );
2191        assert!(
2192            backend
2193                .get_object(&id, Timestamp::now(), None)
2194                .await?
2195                .is_none()
2196        );
2197
2198        // tombstone
2199        let id = make_id();
2200        let tombstone = Tombstone {
2201            target: id.clone(),
2202            time_expires: None,
2203        };
2204        create_tombstone(&backend, &id, &tombstone).await?;
2205        let tombstone = backend
2206            .delete_non_tombstone(&id, Timestamp::now())
2207            .await?
2208            .expect("expected Some(tombstone)");
2209        assert_eq!(tombstone.target, id, "tombstone target must be returned");
2210        assert!(
2211            matches!(
2212                backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2213                TieredMetadata::Tombstone(_)
2214            ),
2215            "tombstone must still exist after delete_non_tombstone"
2216        );
2217
2218        Ok(())
2219    }
2220
2221    // --- Section 4: Compare-and-Write ---
2222
2223    /// Creating a tombstone on an empty row succeeds; a retry of the same CAS also succeeds.
2224    ///
2225    /// After creation, both tiered and legacy APIs reflect the tombstone.
2226    #[tokio::test]
2227    async fn test_cas_create_tombstone() -> Result<()> {
2228        let backend = create_test_backend().await?;
2229
2230        let hv_id = make_id();
2231        let lt_id = ObjectId::random(hv_id.context().clone());
2232        let time_expires = Some(Timestamp::now() + Duration::from_hours(1));
2233        let tombstone = Tombstone {
2234            target: lt_id.clone(),
2235            time_expires,
2236        };
2237
2238        // First create succeeds.
2239        let committed = backend
2240            .compare_and_write(
2241                &hv_id,
2242                None,
2243                TieredWrite::Tombstone(tombstone.clone()),
2244                Timestamp::now(),
2245            )
2246            .await?;
2247        assert!(committed, "expected CAS success on empty row");
2248
2249        // Tiered reads must see the tombstone with the correct target and deadline.
2250        let TieredMetadata::Tombstone(t) = backend
2251            .get_tiered_metadata(&hv_id, Timestamp::now())
2252            .await?
2253        else {
2254            panic!("expected TieredMetadata::Tombstone");
2255        };
2256        assert_eq!(t.target, lt_id, "target must round-trip via r column");
2257        assert_eq!(t.time_expires, time_expires);
2258        match backend
2259            .get_tiered_object(&hv_id, Timestamp::now(), None)
2260            .await?
2261        {
2262            TieredGet::Tombstone(t) => assert_eq!(t.target, lt_id, "round-trip via r column"),
2263            other => panic!("expected TieredGet::Tombstone, got {other:?}"),
2264        }
2265
2266        // Legacy reads must error rather than leak tombstone data.
2267        assert!(
2268            backend
2269                .get_object(&hv_id, Timestamp::now(), None)
2270                .await
2271                .is_err_and(|error| error.kind() == ErrorKind::UnexpectedTombstone)
2272        );
2273        assert!(
2274            backend
2275                .get_metadata(&hv_id, Timestamp::now())
2276                .await
2277                .is_err_and(|error| error.kind() == ErrorKind::UnexpectedTombstone)
2278        );
2279
2280        // Idempotent retry: retry with the same target succeeds
2281        let second = backend
2282            .compare_and_write(
2283                &hv_id,
2284                None,
2285                TieredWrite::Tombstone(tombstone),
2286                Timestamp::now(),
2287            )
2288            .await?;
2289        assert!(second, "idempotent retry");
2290
2291        Ok(())
2292    }
2293
2294    /// Swapping a tombstone target: wrong expected → false, correct expected → true.
2295    #[tokio::test]
2296    async fn test_cas_swap_tombstone() -> Result<()> {
2297        let backend = create_test_backend().await?;
2298
2299        let hv_id = make_id();
2300        let old_lt_id = ObjectId::random(hv_id.context().clone());
2301        let wrong_lt_id = ObjectId::random(hv_id.context().clone());
2302        let new_lt_id = ObjectId::random(hv_id.context().clone());
2303
2304        let tombstone = Tombstone {
2305            target: old_lt_id.clone(),
2306            time_expires: None,
2307        };
2308        create_tombstone(&backend, &hv_id, &tombstone).await?;
2309
2310        // Wrong target: CAS fails, tombstone unchanged.
2311        let write = TieredWrite::Tombstone(Tombstone {
2312            target: new_lt_id.clone(),
2313            time_expires: None,
2314        });
2315        let swapped = backend
2316            .compare_and_write(&hv_id, Some(&wrong_lt_id), write.clone(), Timestamp::now())
2317            .await?;
2318        assert!(!swapped, "expected CAS failure due to wrong target");
2319        match backend
2320            .get_tiered_metadata(&hv_id, Timestamp::now())
2321            .await?
2322        {
2323            TieredMetadata::Tombstone(t) => assert_eq!(t.target, old_lt_id),
2324            other => panic!("expected tombstone, got {other:?}"),
2325        }
2326
2327        // Correct target: CAS succeeds, target updated.
2328        let swapped = backend
2329            .compare_and_write(&hv_id, Some(&old_lt_id), write.clone(), Timestamp::now())
2330            .await?;
2331        assert!(swapped, "expected CAS success with correct target");
2332        match backend
2333            .get_tiered_metadata(&hv_id, Timestamp::now())
2334            .await?
2335        {
2336            TieredMetadata::Tombstone(t) => assert_eq!(t.target, new_lt_id),
2337            other => panic!("expected tombstone, got {other:?}"),
2338        }
2339
2340        // Idempotent retry: same A→B swap returns true.
2341        let retry = backend
2342            .compare_and_write(&hv_id, Some(&old_lt_id), write, Timestamp::now())
2343            .await?;
2344        assert!(retry, "idempotent retry");
2345
2346        Ok(())
2347    }
2348
2349    /// Swapping a tombstone for inline object data: wrong expected → false, correct → true.
2350    #[tokio::test]
2351    async fn test_cas_swap_inline() -> Result<()> {
2352        let backend = create_test_backend().await?;
2353
2354        let id = make_id();
2355        let lt_id = ObjectId::random(id.context().clone());
2356        let wrong_id = ObjectId::random(id.context().clone());
2357
2358        let tombstone = Tombstone {
2359            target: lt_id.clone(),
2360            time_expires: None,
2361        };
2362        create_tombstone(&backend, &id, &tombstone).await?;
2363
2364        // Wrong target: CAS fails, tombstone intact.
2365        let write = TieredWrite::Object(Metadata::default(), Bytes::new());
2366        let swapped = backend
2367            .compare_and_write(&id, Some(&wrong_id), write, Timestamp::now())
2368            .await?;
2369        assert!(!swapped, "expected CAS failure with wrong target");
2370        assert!(matches!(
2371            backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2372            TieredMetadata::Tombstone(_)
2373        ));
2374
2375        // Correct target: CAS succeeds, row becomes an inline object.
2376        let payload = Bytes::from_static(b"hello inline");
2377        let write = TieredWrite::Object(Metadata::default(), payload.clone());
2378        let swapped = backend
2379            .compare_and_write(&id, Some(&lt_id), write.clone(), Timestamp::now())
2380            .await?;
2381        assert!(swapped, "expected CAS success with correct target");
2382        let TieredGet::Object(_, _, stream) = backend
2383            .get_tiered_object(&id, Timestamp::now(), None)
2384            .await?
2385        else {
2386            panic!("expected inline object after swap");
2387        };
2388        assert_eq!(&stream::read_to_vec(stream).await?, payload.as_ref());
2389
2390        // Idempotent retry: row is already inline (no tombstone), same CAS returns true.
2391        let retry = backend
2392            .compare_and_write(&id, Some(&lt_id), write, Timestamp::now())
2393            .await?;
2394        assert!(retry, "idempotent retry");
2395
2396        Ok(())
2397    }
2398
2399    /// CAS-write an object onto an empty row (expected=None, write=Object) succeeds.
2400    #[tokio::test]
2401    async fn test_cas_create_object_on_empty_row() -> Result<()> {
2402        let backend = create_test_backend().await?;
2403
2404        let id = make_id();
2405        let payload = Bytes::from_static(b"cas object");
2406        let write = TieredWrite::Object(Metadata::default(), payload.clone());
2407        let committed = backend
2408            .compare_and_write(&id, None, write, Timestamp::now())
2409            .await?;
2410        assert!(committed, "expected CAS success on empty row");
2411
2412        let TieredGet::Object(_, _, stream) = backend
2413            .get_tiered_object(&id, Timestamp::now(), None)
2414            .await?
2415        else {
2416            panic!("expected Object after CAS-create");
2417        };
2418        assert_eq!(&stream::read_to_vec(stream).await?, payload.as_ref());
2419
2420        Ok(())
2421    }
2422
2423    /// CAS-delete: wrong expected → false; correct expected → true, row gone.
2424    #[tokio::test]
2425    async fn test_cas_delete() -> Result<()> {
2426        let backend = create_test_backend().await?;
2427
2428        let id = make_id();
2429        let lt_id = ObjectId::random(id.context().clone());
2430        let wrong_id = ObjectId::random(id.context().clone());
2431
2432        let tombstone = Tombstone {
2433            target: lt_id.clone(),
2434            time_expires: None,
2435        };
2436        create_tombstone(&backend, &id, &tombstone).await?;
2437
2438        // Wrong target: fails, row preserved.
2439        let deleted = backend
2440            .compare_and_write(&id, Some(&wrong_id), TieredWrite::Delete, Timestamp::now())
2441            .await?;
2442        assert!(!deleted, "expected CAS failure with wrong target");
2443        assert!(matches!(
2444            backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2445            TieredMetadata::Tombstone(_)
2446        ));
2447
2448        // Correct target: succeeds, row gone.
2449        let deleted = backend
2450            .compare_and_write(&id, Some(&lt_id), TieredWrite::Delete, Timestamp::now())
2451            .await?;
2452        assert!(deleted, "expected CAS delete success");
2453        assert!(matches!(
2454            backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2455            TieredMetadata::NotFound
2456        ));
2457
2458        // Idempotent retry: row is already absent (no tombstone), same delete returns true.
2459        let retry = backend
2460            .compare_and_write(&id, Some(&lt_id), TieredWrite::Delete, Timestamp::now())
2461            .await?;
2462        assert!(retry, "idempotent retry");
2463
2464        // Inline object replaced tombstone: Safe to delete since it is an idempotent operation.
2465        let id2 = make_id();
2466        let fake_lt_id = ObjectId::random(id2.context().clone());
2467        let metadata = Metadata::default();
2468        create_object(&backend, &id2, &metadata, b"data", Timestamp::now()).await?;
2469        let deleted = backend
2470            .compare_and_write(
2471                &id2,
2472                Some(&fake_lt_id),
2473                TieredWrite::Delete,
2474                Timestamp::now(),
2475            )
2476            .await?;
2477        assert!(deleted, "expected idempotent deletion");
2478
2479        Ok(())
2480    }
2481
2482    // --- Section 5: Legacy Tombstone Compatibility ---
2483
2484    /// Legacy Manual and TTL tombstones are correctly read via the tiered APIs.
2485    ///
2486    /// Uses `Manual` expiration so `timestamp_micros = -1` (server-assigned ≈ write time)
2487    /// does not trigger immediate expiry.
2488    #[tokio::test]
2489    async fn test_legacy_tombstone_reads() -> Result<()> {
2490        let backend = create_test_backend().await?;
2491
2492        // Manual policy: get_tiered_metadata returns a non-expiring tombstone.
2493        let id = make_id();
2494        write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2495
2496        let TieredMetadata::Tombstone(t) =
2497            backend.get_tiered_metadata(&id, Timestamp::now()).await?
2498        else {
2499            panic!("expected tombstone");
2500        };
2501        assert_eq!(t.time_expires, None);
2502        assert!(matches!(
2503            backend
2504                .get_tiered_object(&id, Timestamp::now(), None)
2505                .await?,
2506            TieredGet::Tombstone(_)
2507        ));
2508
2509        // TTL policy: get_tiered_metadata reconstructs the concrete deadline.
2510        //
2511        // A future cell timestamp (now + TTL) is required so `expires_before` does not
2512        // immediately filter the row.
2513        let id = make_id();
2514        let ttl = Duration::from_hours(2 * 24);
2515        write_legacy_tombstone(&backend, &id, ExpirationPolicy::TimeToLive(ttl), None).await?;
2516
2517        let TieredMetadata::Tombstone(t) =
2518            backend.get_tiered_metadata(&id, Timestamp::now()).await?
2519        else {
2520            panic!("expected TieredMetadata::Tombstone");
2521        };
2522        assert!(t.time_expires.is_some());
2523
2524        Ok(())
2525    }
2526
2527    /// A conditional extension upgrades a legacy TTI tombstone to `r`.
2528    #[tokio::test]
2529    async fn test_legacy_tombstone_tti_upgrade() -> Result<()> {
2530        let backend = create_test_backend().await?;
2531        let id = make_id();
2532        let path = id.as_storage_path().to_string().into_bytes();
2533
2534        let tti = Duration::from_hours(2 * 24);
2535
2536        // Place time_expires near expiry but still in the future.
2537        let old_deadline = Timestamp::now() + Duration::from_mins(1);
2538        write_legacy_tombstone(
2539            &backend,
2540            &id,
2541            ExpirationPolicy::TimeToIdle(tti),
2542            Some(old_deadline),
2543        )
2544        .await?;
2545
2546        // A read observes the legacy row but leaves it unchanged.
2547        let TieredMetadata::Tombstone(_) =
2548            backend.get_tiered_metadata(&id, Timestamp::now()).await?
2549        else {
2550            panic!("expected tombstone");
2551        };
2552        assert_eq!(
2553            backend
2554                .read_row(&path, "test-verify", Timestamp::now(), None)
2555                .await?
2556                .and_then(|row| row.time_expires()),
2557            Some(old_deadline)
2558        );
2559
2560        let requested = Timestamp::now() + tti;
2561        assert!(
2562            backend
2563                .compare_and_update(
2564                    &id,
2565                    Some(&id),
2566                    TieredUpdate::SetExpiry(requested),
2567                    Timestamp::now()
2568                )
2569                .await?
2570        );
2571
2572        // After extension, the row uses the requested timestamp.
2573        let new_deadline = match backend
2574            .read_row(&path, "test-verify", Timestamp::now(), None)
2575            .await?
2576        {
2577            Some(RowData::Tombstone { time_expires, .. }) => time_expires.unwrap(),
2578            _ => panic!("expected tombstone row after extension"),
2579        };
2580
2581        assert!(
2582            new_deadline > old_deadline,
2583            "explicit extension should extend tombstone expiry: {old_deadline:?} -> {new_deadline:?}"
2584        );
2585
2586        Ok(())
2587    }
2588
2589    /// Legacy tombstones are handled correctly by all conditional write operations.
2590    ///
2591    /// Covers: `put_non_tombstone`, `delete_non_tombstone`, CAS-delete for both the
2592    /// legacy-metadata format and the empty-redirect format.
2593    #[tokio::test]
2594    async fn test_legacy_tombstone_conditional_ops() -> Result<()> {
2595        let backend = create_test_backend().await?;
2596
2597        // put_non_tombstone returns Some(target == id) for a legacy tombstone.
2598        let id = make_id();
2599        write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2600        let t_opt = backend
2601            .put_non_tombstone(&id, &Metadata::default(), Bytes::new(), Timestamp::now())
2602            .await?;
2603        assert_eq!(t_opt.map(|t| t.target).as_ref(), Some(&id));
2604
2605        // delete_non_tombstone returns Some(target == id) for a legacy tombstone.
2606        let id = make_id();
2607        write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2608        let t_opt = backend.delete_non_tombstone(&id, Timestamp::now()).await?;
2609        assert_eq!(t_opt.map(|t| t.target).as_ref(), Some(&id));
2610
2611        // CAS-delete succeeds on a legacy-metadata tombstone (target resolves to hv_id).
2612        let id = make_id();
2613        write_legacy_tombstone(&backend, &id, ExpirationPolicy::Manual, None).await?;
2614        let deleted = backend
2615            .compare_and_write(&id, Some(&id), TieredWrite::Delete, Timestamp::now())
2616            .await?;
2617        assert!(
2618            deleted,
2619            "CAS-delete must succeed on legacy-metadata tombstone"
2620        );
2621        assert!(matches!(
2622            backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2623            TieredMetadata::NotFound
2624        ));
2625
2626        // CAS-delete succeeds on an empty-redirect tombstone (target resolves to hv_id).
2627        let id = make_id();
2628        write_empty_redirect_tombstone(&backend, &id).await?;
2629        let deleted = backend
2630            .compare_and_write(&id, Some(&id), TieredWrite::Delete, Timestamp::now())
2631            .await?;
2632        assert!(
2633            deleted,
2634            "CAS-delete must succeed on empty-redirect tombstone"
2635        );
2636        assert!(matches!(
2637            backend.get_tiered_metadata(&id, Timestamp::now()).await?,
2638            TieredMetadata::NotFound
2639        ));
2640
2641        Ok(())
2642    }
2643
2644    /// An empty `r` value falls back to the HV id when resolving the tombstone target.
2645    #[tokio::test]
2646    async fn test_empty_redirect_falls_back_to_hv_id() -> Result<()> {
2647        let backend = create_test_backend().await?;
2648        let id = make_id();
2649
2650        write_empty_redirect_tombstone(&backend, &id).await?;
2651        match backend.get_tiered_metadata(&id, Timestamp::now()).await? {
2652            TieredMetadata::Tombstone(t) => assert_eq!(t.target, id, "must fall back to hv_id"),
2653            other => panic!("expected tombstone, got {other:?}"),
2654        }
2655
2656        Ok(())
2657    }
2658
2659    // --- Section 6: Expired Tombstone Handling ---
2660
2661    /// CAS with `current=None` must succeed when the row holds an expired
2662    /// tombstone. The physical row still exists but is logically gone.
2663    #[tokio::test]
2664    async fn test_cas_create_tombstone_over_expired() -> Result<()> {
2665        let backend = create_test_backend().await?;
2666
2667        let id = make_id();
2668        let old_lt_id = ObjectId::random(id.context().clone());
2669        let old_tombstone = Tombstone {
2670            target: old_lt_id,
2671            time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2672        };
2673        create_tombstone(&backend, &id, &old_tombstone).await?;
2674
2675        let new_lt_id = ObjectId::random(id.context().clone());
2676        let new_tombstone = Tombstone {
2677            target: new_lt_id.clone(),
2678            time_expires: Some(Timestamp::now() + Duration::from_hours(1)),
2679        };
2680        let committed = backend
2681            .compare_and_write(
2682                &id,
2683                None,
2684                TieredWrite::Tombstone(new_tombstone),
2685                Timestamp::now(),
2686            )
2687            .await?;
2688        assert!(
2689            committed,
2690            "CAS with current=None must succeed over an expired tombstone"
2691        );
2692
2693        let TieredMetadata::Tombstone(t) =
2694            backend.get_tiered_metadata(&id, Timestamp::now()).await?
2695        else {
2696            panic!("expected new tombstone to be readable");
2697        };
2698        assert_eq!(t.target, new_lt_id);
2699
2700        Ok(())
2701    }
2702
2703    /// `put_non_tombstone` must succeed when the row holds only an expired
2704    /// tombstone — the expired row is logically absent.
2705    #[tokio::test]
2706    async fn test_put_non_tombstone_over_expired() -> Result<()> {
2707        let backend = create_test_backend().await?;
2708
2709        let id = make_id();
2710        let lt_id = ObjectId::random(id.context().clone());
2711        let tombstone = Tombstone {
2712            target: lt_id,
2713            time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2714        };
2715        create_tombstone(&backend, &id, &tombstone).await?;
2716
2717        let result = backend
2718            .put_non_tombstone(
2719                &id,
2720                &Metadata::default(),
2721                Bytes::from_static(b"data"),
2722                Timestamp::now(),
2723            )
2724            .await?;
2725        assert_eq!(
2726            result, None,
2727            "put_non_tombstone must succeed (return None) over an expired tombstone"
2728        );
2729
2730        let (_, _, stream) = backend
2731            .get_object(&id, Timestamp::now(), None)
2732            .await?
2733            .unwrap();
2734        assert_eq!(&stream::read_to_vec(stream).await?, b"data");
2735
2736        Ok(())
2737    }
2738
2739    // --- Range Request Tests ---
2740
2741    async fn put_range_test_object(backend: &BigTableBackend) -> Result<ObjectId> {
2742        let id = make_id();
2743        let metadata = Metadata {
2744            content_type: "text/plain".into(),
2745            ..Default::default()
2746        };
2747        let payload = b"Hello, range requests!";
2748        backend
2749            .put_object(
2750                &id,
2751                &metadata,
2752                stream::single(payload.as_slice()),
2753                Timestamp::now(),
2754            )
2755            .await?;
2756        Ok(id)
2757    }
2758
2759    #[tokio::test]
2760    async fn get_object_range_bounded() -> Result<()> {
2761        let backend = create_test_backend().await?;
2762        let id = put_range_test_object(&backend).await?;
2763
2764        let (_, content_range, stream) = backend
2765            .get_object(&id, Timestamp::now(), Some(ByteRange::Bounded(7, 11)))
2766            .await?
2767            .unwrap();
2768        let data = stream::read_to_vec(stream).await?;
2769        assert_eq!(&data, b"range");
2770
2771        let content_range = content_range.unwrap();
2772        assert_eq!(content_range.start, 7);
2773        assert_eq!(content_range.end, 11);
2774        assert_eq!(content_range.total, 22);
2775
2776        Ok(())
2777    }
2778
2779    #[tokio::test]
2780    async fn get_object_range_from() -> Result<()> {
2781        let backend = create_test_backend().await?;
2782        let id = put_range_test_object(&backend).await?;
2783
2784        let (_, content_range, stream) = backend
2785            .get_object(&id, Timestamp::now(), Some(ByteRange::From(7)))
2786            .await?
2787            .unwrap();
2788        let data = stream::read_to_vec(stream).await?;
2789        assert_eq!(&data, b"range requests!");
2790
2791        let content_range = content_range.unwrap();
2792        assert_eq!(content_range.start, 7);
2793        assert_eq!(content_range.end, 21);
2794        assert_eq!(content_range.total, 22);
2795
2796        Ok(())
2797    }
2798
2799    #[tokio::test]
2800    async fn get_object_range_last() -> Result<()> {
2801        let backend = create_test_backend().await?;
2802        let id = put_range_test_object(&backend).await?;
2803
2804        let (_, content_range, stream) = backend
2805            .get_object(&id, Timestamp::now(), Some(ByteRange::Last(9)))
2806            .await?
2807            .unwrap();
2808        let data = stream::read_to_vec(stream).await?;
2809        assert_eq!(&data, b"requests!");
2810
2811        let content_range = content_range.unwrap();
2812        assert_eq!(content_range.start, 13);
2813        assert_eq!(content_range.end, 21);
2814        assert_eq!(content_range.total, 22);
2815
2816        Ok(())
2817    }
2818
2819    #[tokio::test]
2820    async fn get_object_range_unsatisfiable() -> Result<()> {
2821        let backend = create_test_backend().await?;
2822        let id = put_range_test_object(&backend).await?;
2823
2824        match backend
2825            .get_object(&id, Timestamp::now(), Some(ByteRange::From(100)))
2826            .await
2827        {
2828            Err(error) if matches!(error.kind(), ErrorKind::RangeNotSatisfiable { total: 22 }) => {}
2829            Ok(_) => panic!("expected RangeNotSatisfiable, got Ok"),
2830            Err(e) => panic!("expected RangeNotSatisfiable, got {e:?}"),
2831        }
2832
2833        Ok(())
2834    }
2835
2836    #[tokio::test]
2837    async fn get_object_no_range_returns_full_payload() -> Result<()> {
2838        let backend = create_test_backend().await?;
2839        let id = put_range_test_object(&backend).await?;
2840
2841        let (_, content_range, stream) = backend
2842            .get_object(&id, Timestamp::now(), None)
2843            .await?
2844            .unwrap();
2845        let data = stream::read_to_vec(stream).await?;
2846        assert_eq!(&data, b"Hello, range requests!");
2847        assert!(content_range.is_none());
2848
2849        Ok(())
2850    }
2851
2852    #[test]
2853    fn row_size_counts_the_key_and_every_cell() {
2854        let path = b"attachments/org.1/objects/abc";
2855        let (mutations, size) =
2856            object_mutations(path, Metadata::default(), b"0123456789".to_vec()).unwrap();
2857
2858        // The key, the 10-byte payload, and the serialized metadata. `object_mutations`
2859        // stamps the size into the metadata before serializing it, so the expected length
2860        // has to account for that too.
2861        let stamped = Metadata {
2862            size: Some(10),
2863            ..Default::default()
2864        };
2865        let metadata_len = serde_json::to_vec(&stamped).unwrap().len();
2866        let expected = (path.len() + 10 + metadata_len) as u64;
2867
2868        assert_eq!(row_size(path, &mutations), expected);
2869        assert_eq!(size, expected, "the size handed back matches the mutations");
2870    }
2871
2872    #[test]
2873    fn row_size_is_nonzero_for_tombstones() {
2874        let path = b"attachments/org.1/objects/abc";
2875        let time_expires = Timestamp::now() + Duration::from_secs(60);
2876        let tombstone = Tombstone {
2877            target: ObjectId::from_storage_path("attachments/org.1/objects/abc/0199").unwrap(),
2878            time_expires: Some(time_expires),
2879        };
2880        let mutations = tombstone_mutations(&tombstone);
2881
2882        assert_eq!(mutations.len(), 2);
2883        let set_cell = mutations[1].mutation.as_ref().unwrap();
2884        let mutation::Mutation::SetCell(set_cell) = set_cell else {
2885            panic!("expected redirect SetCell mutation");
2886        };
2887        assert_eq!(set_cell.family_name, FAMILY_GC);
2888        assert_eq!(set_cell.column_qualifier, COLUMN_REDIRECT);
2889        assert_eq!(set_cell.timestamp_micros, time_expires.as_micros() as i64);
2890        assert!(row_size(path, &mutations) > path.len() as u64);
2891    }
2892
2893    #[cfg(feature = "storage-cogs")]
2894    #[tokio::test]
2895    async fn change_stream_reports_writes_and_deletes() -> Result<()> {
2896        let (backend, producer) = create_test_backend_with_change_stream().await?;
2897        let id = make_id();
2898        let metadata = Metadata {
2899            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(3600)),
2900            time_expires: Some(Timestamp::now() + Duration::from_secs(3600)),
2901            ..Default::default()
2902        };
2903
2904        backend
2905            .put_object(
2906                &id,
2907                &metadata,
2908                stream::single::<crate::stream::ClientError>(b"hello".to_vec()),
2909                Timestamp::now(),
2910            )
2911            .await?;
2912        backend.delete_object(&id, Timestamp::now()).await?;
2913
2914        let records = producer.records();
2915        assert_eq!(records.len(), 2);
2916
2917        assert_eq!(records[0].op_type, OpType::Write);
2918        assert_eq!(records[0].app_feature, "testing");
2919        assert_eq!(records[0].shared_resource_id, "bigtable_objectstore");
2920        // Key plus payload plus metadata, so strictly more than the payload alone.
2921        assert!(records[0].size.unwrap() > b"hello".len() as u64);
2922        assert!(records[0].expiration_time.is_some());
2923
2924        assert_eq!(records[1].op_type, OpType::Delete);
2925        assert_eq!(records[1].size, None);
2926        assert_eq!(records[1].record_id, records[0].record_id);
2927
2928        Ok(())
2929    }
2930
2931    #[cfg(feature = "storage-cogs")]
2932    #[tokio::test]
2933    async fn delete_non_tombstone_reclaims_expired_rows() -> Result<()> {
2934        let (backend, producer) = create_test_backend_with_change_stream().await?;
2935
2936        // An expired tombstone is past its lifetime: reclaimed, not handed to the caller.
2937        // This test serves as documentation of that potentially surprising behavior.
2938        let id = make_id();
2939        let tombstone = Tombstone {
2940            target: ObjectId::random(id.context().clone()),
2941            time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2942        };
2943        create_tombstone(&backend, &id, &tombstone).await?;
2944        assert_eq!(
2945            backend.delete_non_tombstone(&id, Timestamp::now()).await?,
2946            None,
2947            "an expired tombstone must not be returned to the caller"
2948        );
2949        let records = producer.records();
2950        assert_eq!(records.len(), 1, "the expired tombstone must be reclaimed");
2951        assert_eq!(records[0].op_type, OpType::Delete);
2952
2953        // The same holds for an object row (expired or otherwise).
2954        let id = make_id();
2955        let metadata = Metadata {
2956            expiration_policy: ExpirationPolicy::TimeToLive(Duration::from_secs(0)),
2957            time_expires: Some(Timestamp::now() - Duration::from_secs(1)),
2958            ..Default::default()
2959        };
2960        create_object(&backend, &id, &metadata, b"gone", Timestamp::now()).await?;
2961        producer.clear();
2962        assert_eq!(
2963            backend.delete_non_tombstone(&id, Timestamp::now()).await?,
2964            None
2965        );
2966        let records = producer.records();
2967        assert_eq!(records.len(), 1, "the object row must be reclaimed");
2968        assert_eq!(records[0].op_type, OpType::Delete);
2969
2970        Ok(())
2971    }
2972
2973    #[cfg(feature = "storage-cogs")]
2974    #[tokio::test]
2975    async fn change_stream_reports_tombstone_rows() -> Result<()> {
2976        let (backend, producer) = create_test_backend_with_change_stream().await?;
2977        let id = make_id();
2978        let target = new_test_revision(&id);
2979
2980        let tombstone = Tombstone {
2981            target: target.clone(),
2982            time_expires: Some(Timestamp::now() + Duration::from_secs(3600)),
2983        };
2984        let written = backend
2985            .compare_and_write(
2986                &id,
2987                None,
2988                TieredWrite::Tombstone(tombstone),
2989                Timestamp::now(),
2990            )
2991            .await?;
2992        assert!(written);
2993
2994        let records = producer.records();
2995        assert_eq!(records.len(), 1);
2996        assert_eq!(records[0].op_type, OpType::Write);
2997        assert!(
2998            records[0].size.unwrap() > 0,
2999            "tombstone rows occupy storage and must not report zero"
3000        );
3001        assert!(records[0].expiration_time.is_some());
3002
3003        Ok(())
3004    }
3005
3006    #[cfg(feature = "storage-cogs")]
3007    #[tokio::test]
3008    async fn change_stream_reports_expiry_extension_as_an_update() -> Result<()> {
3009        let (backend, producer) = create_test_backend_with_change_stream().await?;
3010        let id = make_id();
3011        let metadata = Metadata {
3012            expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(3600)),
3013            time_expires: Some(Timestamp::now() + Duration::from_secs(1)),
3014            ..Default::default()
3015        };
3016
3017        backend
3018            .put_object(
3019                &id,
3020                &metadata,
3021                stream::single::<crate::stream::ClientError>(b"hello".to_vec()),
3022                Timestamp::now(),
3023            )
3024            .await?;
3025        producer.clear();
3026
3027        backend
3028            .set_expiry(
3029                &id,
3030                Timestamp::now() + Duration::from_secs(3600),
3031                Timestamp::now(),
3032            )
3033            .await?;
3034
3035        let records = producer.records();
3036        assert_eq!(records.len(), 1, "expected exactly one extension report");
3037        assert_eq!(records[0].op_type, OpType::Update);
3038        assert_eq!(
3039            records[0].size, None,
3040            "an extension does not change the size"
3041        );
3042        assert!(records[0].expiration_time.is_some());
3043
3044        Ok(())
3045    }
3046
3047    #[cfg(feature = "storage-cogs")]
3048    fn new_test_revision(id: &ObjectId) -> ObjectId {
3049        ObjectId {
3050            context: id.context.clone(),
3051            key: format!("{}/{}", id.key, uuid::Uuid::now_v7()),
3052        }
3053    }
3054}