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