Skip to main content

objectstore_service/backend/
common.rs

1//! Shared trait definition and types for all backends.
2
3use std::fmt;
4use std::num::NonZeroU64;
5
6use objectstore_types::metadata::Metadata;
7use objectstore_types::range::{ByteRange, ContentRange};
8use objectstore_types::resumable::UploadProgress;
9use objectstore_types::time::Timestamp;
10
11use bytes::Bytes;
12
13use crate::error::{ErrorKind, Result};
14use crate::id::ObjectId;
15use crate::multipart::{
16    AbortMultipartResponse, CompleteMultipartResponse, CompletedPart, InitiateMultipartResponse,
17    ListPartsResponse, PartNumber, UploadId, UploadPartResponse,
18};
19use crate::resumable::BackendToken;
20use crate::stream::{ClientStream, PayloadStream};
21
22/// User agent string used for outgoing requests.
23///
24/// This intentionally has a "sentry" prefix so that it can easily be traced back to us.
25pub const USER_AGENT: &str = concat!("sentry-objectstore/", env!("CARGO_PKG_VERSION"));
26
27/// Backend response for put operations.
28pub type PutResponse = ();
29/// Backend response for get operations.
30pub type GetResponse = Option<(Metadata, Option<ContentRange>, PayloadStream)>;
31/// Backend response for metadata-only get operations.
32pub type MetadataResponse = Option<Metadata>;
33/// Backend response for delete operations.
34pub type DeleteResponse = ();
35
36/// Trait implemented by all storage backends.
37///
38/// Object operations take `access_time`, the timestamp of the caller's operation.
39/// Use it to decide whether an object or redirect has expired, so all steps of
40/// an operation use the same time, including retries and calls to other backends.
41/// An object is expired when its deadline is strictly earlier than `access_time`.
42/// Writes preserve the creation time and deadline in the supplied metadata.
43#[async_trait::async_trait]
44pub trait Backend: fmt::Debug + Send + Sync + 'static {
45    /// The backend name, used for diagnostics.
46    fn name(&self) -> &'static str;
47
48    /// Stores an object at the given path with the given metadata.
49    async fn put_object(
50        &self,
51        id: &ObjectId,
52        metadata: &Metadata,
53        stream: ClientStream,
54        access_time: Timestamp,
55    ) -> Result<PutResponse>;
56
57    /// Retrieves (part of) an object at the given path, returning its metadata, a description of
58    /// the part being returned, and the payload.
59    async fn get_object(
60        &self,
61        id: &ObjectId,
62        access_time: Timestamp,
63        range: Option<ByteRange>,
64    ) -> Result<GetResponse>;
65
66    /// Retrieves only the metadata for an object, without the payload.
67    async fn get_metadata(
68        &self,
69        id: &ObjectId,
70        access_time: Timestamp,
71    ) -> Result<MetadataResponse> {
72        Ok(self
73            .get_object(id, access_time, None)
74            .await?
75            .map(|(metadata, _range, _stream)| metadata))
76    }
77
78    /// Extends the deadline of an existing object with expiration policy.
79    ///
80    /// This only changes the stored deadline: the expiration policy, duration,
81    /// payload, and all other metadata remain unchanged.
82    ///
83    /// Returns `true` when the deadline was extended or was already at least as
84    /// late as `expire_at`. Returns `false` when the object is absent, expired,
85    /// manually expired, or changed concurrently.
86    async fn set_expiry(
87        &self,
88        id: &ObjectId,
89        expire_at: Timestamp,
90        access_time: Timestamp,
91    ) -> Result<bool>;
92
93    /// Deletes the object at the given path.
94    async fn delete_object(&self, id: &ObjectId, access_time: Timestamp) -> Result<DeleteResponse>;
95
96    /// Waits for any outstanding background operations to complete before shutdown.
97    ///
98    /// The default implementation is a no-op. Backends that spawn background tasks
99    /// (such as [`TieredStorage`](super::tiered::TieredStorage)) should override this
100    /// to wait for those tasks to complete.
101    async fn join(&self) {}
102
103    /// Borrows this backend as a [`MultipartUploadBackend`] if supported.
104    ///
105    /// The default returns an [`ErrorKind::Unsupported`]. Backends that implement
106    /// [`MultipartUploadBackend`] should override this to return `Ok(self)`.
107    fn as_multipart_upload_backend(&self) -> Result<&dyn MultipartUploadBackend> {
108        Err(ErrorKind::Unsupported.into())
109    }
110
111    /// Creates a resumable upload session for the object at `id`.
112    ///
113    /// Object metadata and its total length are declared upfront and cannot be mutated
114    /// during the upload.
115    ///
116    /// The returned string is opaque backend-defined state. [`StorageService`](crate::StorageService)
117    /// protects it before exposing the session token outside the service layer.
118    ///
119    /// Returns `Ok(None)` when this backend cannot store the described object resumably. Declining
120    /// is a routine outcome rather than an error, and the default implementation declines.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error only when the backend supports resumable uploads but failed to open the
125    /// session.
126    async fn create_upload_session(
127        &self,
128        id: &ObjectId,
129        metadata: &Metadata,
130        total_length: NonZeroU64,
131    ) -> Result<Option<BackendToken>> {
132        let _ = (id, metadata, total_length);
133        Ok(None)
134    }
135
136    /// Writes a chunk of `content_length` bytes at `offset` into an open session.
137    ///
138    /// A backend may acknowledge fewer bytes than the chunk supplied, for example by persisting
139    /// only an aligned prefix. Callers must continue from the authoritative offset in the returned
140    /// [`UploadProgress`], or query [`Self::upload_offset`] after an ambiguous failure. A backend
141    /// may or may not accept a replay starting before its persisted offset.
142    ///
143    /// [`UploadProgress::Complete`] means the upload is terminal and the object is available
144    /// through this backend's normal read methods. A backend that composes another backend must
145    /// finish its own publication work before returning that outcome.
146    ///
147    /// A `content_length` of zero is valid. It writes nothing and reports the offset the backend
148    /// holds.
149    ///
150    /// Returns [`ErrorKind::UnknownUploadSession`] when `token` does not identify an open session,
151    /// and [`ErrorKind::ChunkExceedsUploadLength`] when the chunk would exceed the total length
152    /// declared when the session was created.
153    async fn put_chunk(
154        &self,
155        id: &ObjectId,
156        token: &BackendToken,
157        offset: u64,
158        content_length: u64,
159        stream: ClientStream,
160    ) -> Result<UploadProgress> {
161        let _ = (id, token, offset, content_length, stream);
162        Err(ErrorKind::Unsupported.into())
163    }
164
165    /// Reports how far the session has progressed.
166    ///
167    /// This can return [`UploadProgress::Complete`] repeatedly after the final chunk, including
168    /// when its original response was lost. A composed backend may finish pending idempotent
169    /// publication work before returning that terminal outcome.
170    ///
171    /// Returns [`ErrorKind::UnknownUploadSession`] when `token` does not identify a known session.
172    async fn upload_offset(&self, id: &ObjectId, token: &BackendToken) -> Result<UploadProgress> {
173        let _ = (id, token);
174        Err(ErrorKind::Unsupported.into())
175    }
176
177    /// Cancels an upload session, discarding whatever was uploaded.
178    ///
179    /// Returns [`ErrorKind::UnknownUploadSession`] when `token` does not identify an open session.
180    async fn cancel_upload(&self, id: &ObjectId, token: &BackendToken) -> Result<()> {
181        let _ = (id, token);
182        Err(ErrorKind::Unsupported.into())
183    }
184}
185
186/// Trait for backends that support our S3-style multipart upload protocol.
187#[async_trait::async_trait]
188pub trait MultipartUploadBackend: Backend + fmt::Debug + Send + Sync + 'static {
189    /// Initiates a new multipart upload at `id` with the given metadata.
190    async fn initiate_multipart(
191        &self,
192        id: &ObjectId,
193        metadata: &Metadata,
194    ) -> Result<InitiateMultipartResponse>;
195
196    /// Uploads a single part of the upload identified by `(id, upload_id)`.
197    async fn upload_part(
198        &self,
199        id: &ObjectId,
200        upload_id: &UploadId,
201        part_number: PartNumber,
202        content_length: u64,
203        content_md5: Option<&str>,
204        body: ClientStream,
205    ) -> Result<UploadPartResponse>;
206
207    /// Lists the parts uploaded so far for `(id, upload_id)`.
208    async fn list_parts(
209        &self,
210        id: &ObjectId,
211        upload_id: &UploadId,
212        max_parts: Option<u32>,
213        part_number_marker: Option<PartNumber>,
214    ) -> Result<ListPartsResponse>;
215
216    /// Aborts the upload identified by `(id, upload_id)`.
217    async fn abort_multipart(
218        &self,
219        id: &ObjectId,
220        upload_id: &UploadId,
221    ) -> Result<AbortMultipartResponse>;
222
223    /// Finalizes the upload identified by `(id, upload_id)` with the given
224    /// ordered list of parts.
225    ///
226    /// Note that this returns `Result<Option<CompleteMultipartError>>`.
227    /// It's therefore possible to get `Ok(Some(err))`, meaning that at the server level this will
228    /// translate to HTTP `200 OK` with an error contained in the response body.
229    /// We need to do it this way to mirror backends that also behave like this (namely S3 and
230    /// GCS).
231    async fn complete_multipart(
232        &self,
233        id: &ObjectId,
234        upload_id: &UploadId,
235        parts: Vec<CompletedPart>,
236        access_time: Timestamp,
237    ) -> Result<CompleteMultipartResponse>;
238}
239
240/// Trait for backends that support tombstone-conditional operations.
241///
242/// Only backends suitable for the high-volume tier of
243/// [`TieredStorage`](super::tiered::TieredStorage) implement this trait.
244/// The conditional methods provide atomic operations to avoid overwriting
245/// redirect tombstones.
246#[async_trait::async_trait]
247pub trait HighVolumeBackend: Backend {
248    /// Writes the object only if NO redirect tombstone exists at this key.
249    ///
250    /// Returns `None` after storing the object, or `Some(tombstone)` (skipping
251    /// the write) when a redirect tombstone is present. The returned tombstone
252    /// carries the target LT `ObjectId` so the caller can route without a
253    /// second round trip.
254    ///
255    /// Takes [`Bytes`] instead of a [`ClientStream`] because callers on this
256    /// path have already fully buffered the payload.
257    async fn put_non_tombstone(
258        &self,
259        id: &ObjectId,
260        metadata: &Metadata,
261        payload: Bytes,
262        access_time: Timestamp,
263    ) -> Result<Option<Tombstone>>;
264
265    /// Retrieves (part of) an object with explicit tombstone awareness.
266    ///
267    /// Returns [`TieredGet::Tombstone`] instead of synthesizing a tombstone
268    /// object, making the caller's routing logic a compile-time distinction.
269    async fn get_tiered_object(
270        &self,
271        id: &ObjectId,
272        access_time: Timestamp,
273        range: Option<ByteRange>,
274    ) -> Result<TieredGet>;
275
276    /// Retrieves only metadata with explicit tombstone awareness.
277    ///
278    /// Implementations should skip the payload column where possible to avoid
279    /// fetching up to 1 MiB of data just to discover a tombstone.
280    async fn get_tiered_metadata(
281        &self,
282        id: &ObjectId,
283        access_time: Timestamp,
284    ) -> Result<TieredMetadata>;
285
286    /// Deletes the object only if it is NOT a redirect tombstone.
287    ///
288    /// Returns `None` after deleting the row (or if the row was already absent),
289    /// or `Some(tombstone)` (leaving the row intact) when the object is a
290    /// redirect tombstone. The returned tombstone carries the target LT
291    /// `ObjectId` so the caller can delete from long-term storage directly,
292    /// without a second round trip.
293    async fn delete_non_tombstone(
294        &self,
295        id: &ObjectId,
296        access_time: Timestamp,
297    ) -> Result<Option<Tombstone>>;
298
299    /// Atomically mutates the row if the current redirect state matches.
300    ///
301    /// `current` determines the precondition:
302    /// - `None`: succeeds only if no live tombstone exists (row absent, inline,
303    ///   or tombstone present but logically expired).
304    /// - `Some(target)`: succeeds only if a tombstone exists whose redirect
305    ///   resolves to `target`.
306    ///
307    /// **This operation is idempotent:** if the object is already in the target
308    /// state, it returns `true`. Whether the mutation runs again is up to the
309    /// implementation.
310    ///
311    /// Returns `true` on success or idempotent match, `false` if a conflicting
312    /// state was found (another writer won the race).
313    async fn compare_and_write(
314        &self,
315        id: &ObjectId,
316        current: Option<&ObjectId>,
317        write: TieredWrite,
318        access_time: Timestamp,
319    ) -> Result<bool>;
320
321    /// Atomically updates an existing row if its kind and redirect target match.
322    ///
323    /// `current = None` requires a live inline object. `Some(target)` requires
324    /// a live redirect to exactly that target. Updates never authorize creation
325    /// of an absent row.
326    ///
327    /// Returns `true` when the update was applied or its requested state was
328    /// already satisfied. Returns `false` for an absent, expired, or conflicting
329    /// entry.
330    async fn compare_and_update(
331        &self,
332        id: &ObjectId,
333        current: Option<&ObjectId>,
334        update: TieredUpdate,
335        access_time: Timestamp,
336    ) -> Result<bool>;
337}
338
339/// Information about a redirect tombstone in the high-volume backend.
340#[derive(Clone, Debug, PartialEq, Eq)]
341pub struct Tombstone {
342    /// The [`ObjectId`] of the object in the long-term backend.
343    ///
344    /// For legacy tombstones with an empty `r` column, the HV backend resolves
345    /// this to the HV `ObjectId` itself before surfacing the tombstone to callers.
346    pub target: ObjectId,
347
348    /// The concrete deadline stored on the redirect.
349    pub time_expires: Option<Timestamp>,
350}
351
352impl Tombstone {
353    /// Returns whether the tombstone has expired at the given time.
354    pub fn is_expired(&self, now: Timestamp) -> bool {
355        self.time_expires.is_some_and(|deadline| deadline < now)
356    }
357}
358
359/// Typed response from [`HighVolumeBackend::get_tiered_object`].
360pub enum TieredGet {
361    /// A real object was found.
362    Object(Metadata, Option<ContentRange>, PayloadStream),
363    /// A redirect tombstone was found; the real object lives in the long-term backend.
364    Tombstone(Tombstone),
365    /// No entry exists at this key.
366    NotFound,
367}
368
369impl fmt::Debug for TieredGet {
370    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
371        match self {
372            TieredGet::Object(metadata, content_range, _stream) => f
373                .debug_tuple("Object")
374                .field(metadata)
375                .field(content_range)
376                .finish_non_exhaustive(),
377            TieredGet::Tombstone(info) => f.debug_tuple("Tombstone").field(info).finish(),
378            TieredGet::NotFound => write!(f, "NotFound"),
379        }
380    }
381}
382
383/// Typed metadata-only response from [`HighVolumeBackend::get_tiered_metadata`].
384#[derive(Debug)]
385pub enum TieredMetadata {
386    /// Metadata for a real object was found.
387    Object(Metadata),
388    /// A redirect tombstone was found; the real object lives in the long-term backend.
389    Tombstone(Tombstone),
390    /// No entry exists at this key.
391    NotFound,
392}
393
394/// The write operation performed by [`HighVolumeBackend::compare_and_write`].
395#[derive(Clone, Debug)]
396pub enum TieredWrite {
397    /// Write a redirect tombstone.
398    Tombstone(Tombstone),
399    /// Write inline object data.
400    Object(Metadata, Bytes),
401    /// Delete the row entirely.
402    Delete,
403}
404
405impl TieredWrite {
406    /// Returns the tombstone target if this is a tombstone write, or `None` otherwise.
407    pub fn target(&self) -> Option<&ObjectId> {
408        match self {
409            TieredWrite::Tombstone(t) => Some(&t.target),
410            _ => None,
411        }
412    }
413}
414
415/// The in-place operation performed by [`HighVolumeBackend::compare_and_update`].
416#[derive(Clone, Debug)]
417pub enum TieredUpdate {
418    /// Extend the deadline while preserving all other stored data.
419    SetExpiry(Timestamp),
420}
421
422/// Creates a reqwest client with required defaults.
423///
424/// Automatic decompression is disabled because backends store pre-compressed
425/// payloads and manage `Content-Encoding` themselves.
426pub(super) fn reqwest_client() -> reqwest::Client {
427    reqwest::Client::builder()
428        .user_agent(USER_AGENT)
429        .hickory_dns(true)
430        .http1_only()
431        .no_zstd()
432        .no_brotli()
433        .no_gzip()
434        .no_deflate()
435        .build()
436        // INVARIANT: Building fails only if the TLS backend cannot be initialized, which
437        // is checked at startup when the rustls crypto provider is installed.
438        .expect("failed to build backend HTTP client")
439}