objectstore_service/backend/common.rs
1//! Shared trait definition and types for all backends.
2
3use std::fmt;
4
5use objectstore_types::metadata::{ExpirationPolicy, Metadata};
6
7use bytes::Bytes;
8
9use crate::error::Result;
10use crate::id::ObjectId;
11use crate::stream::{ClientStream, PayloadStream};
12
13/// User agent string used for outgoing requests.
14///
15/// This intentionally has a "sentry" prefix so that it can easily be traced back to us.
16pub const USER_AGENT: &str = concat!("sentry-objectstore/", env!("CARGO_PKG_VERSION"));
17
18/// Backend response for put operations.
19pub type PutResponse = ();
20/// Backend response for get operations.
21pub type GetResponse = Option<(Metadata, PayloadStream)>;
22/// Backend response for metadata-only get operations.
23pub type MetadataResponse = Option<Metadata>;
24/// Backend response for delete operations.
25pub type DeleteResponse = ();
26
27/// Trait implemented by all storage backends.
28#[async_trait::async_trait]
29pub trait Backend: fmt::Debug + Send + Sync + 'static {
30 /// The backend name, used for diagnostics.
31 fn name(&self) -> &'static str;
32
33 /// Stores an object at the given path with the given metadata.
34 async fn put_object(
35 &self,
36 id: &ObjectId,
37 metadata: &Metadata,
38 stream: ClientStream,
39 ) -> Result<PutResponse>;
40
41 /// Retrieves an object at the given path, returning its metadata and a stream of bytes.
42 async fn get_object(&self, id: &ObjectId) -> Result<GetResponse>;
43
44 /// Retrieves only the metadata for an object, without the payload.
45 async fn get_metadata(&self, id: &ObjectId) -> Result<MetadataResponse> {
46 Ok(self
47 .get_object(id)
48 .await?
49 .map(|(metadata, _stream)| metadata))
50 }
51
52 /// Deletes the object at the given path.
53 async fn delete_object(&self, id: &ObjectId) -> Result<DeleteResponse>;
54
55 /// Waits for any outstanding background operations to complete before shutdown.
56 ///
57 /// The default implementation is a no-op. Backends that spawn background tasks
58 /// (such as [`TieredStorage`](super::tiered::TieredStorage)) should override this
59 /// to wait for those tasks to complete.
60 async fn join(&self) {}
61}
62
63/// Trait for backends that support tombstone-conditional operations.
64///
65/// Only backends suitable for the high-volume tier of
66/// [`TieredStorage`](super::tiered::TieredStorage) implement this trait.
67/// The conditional methods provide atomic operations to avoid overwriting
68/// redirect tombstones.
69#[async_trait::async_trait]
70pub trait HighVolumeBackend: Backend {
71 /// Writes the object only if NO redirect tombstone exists at this key.
72 ///
73 /// Returns `None` after storing the object, or `Some(tombstone)` (skipping
74 /// the write) when a redirect tombstone is present. The returned tombstone
75 /// carries the target LT `ObjectId` so the caller can route without a
76 /// second round trip.
77 ///
78 /// Takes [`Bytes`] instead of a [`ClientStream`] because callers on this
79 /// path have already fully buffered the payload.
80 async fn put_non_tombstone(
81 &self,
82 id: &ObjectId,
83 metadata: &Metadata,
84 payload: Bytes,
85 ) -> Result<Option<Tombstone>>;
86
87 /// Retrieves an object with explicit tombstone awareness.
88 ///
89 /// Returns [`TieredGet::Tombstone`] instead of synthesizing a tombstone
90 /// object, making the caller's routing logic a compile-time distinction.
91 async fn get_tiered_object(&self, id: &ObjectId) -> Result<TieredGet>;
92
93 /// Retrieves only metadata with explicit tombstone awareness.
94 ///
95 /// Implementations should skip the payload column where possible to avoid
96 /// fetching up to 1 MiB of data just to discover a tombstone.
97 async fn get_tiered_metadata(&self, id: &ObjectId) -> Result<TieredMetadata>;
98
99 /// Deletes the object only if it is NOT a redirect tombstone.
100 ///
101 /// Returns `None` after deleting the row (or if the row was already absent),
102 /// or `Some(tombstone)` (leaving the row intact) when the object is a
103 /// redirect tombstone. The returned tombstone carries the target LT
104 /// `ObjectId` so the caller can delete from long-term storage directly,
105 /// without a second round trip.
106 async fn delete_non_tombstone(&self, id: &ObjectId) -> Result<Option<Tombstone>>;
107
108 /// Atomically mutates the row if the current redirect state matches.
109 ///
110 /// `current` determines the precondition:
111 /// - `None`: succeeds only if no tombstone exists (row absent or inline).
112 /// - `Some(target)`: succeeds only if a tombstone exists whose redirect
113 /// resolves to `target`.
114 ///
115 /// **This operation is idempotent:** if the object is already in the target
116 /// state, it returns `true`. Whether the mutation runs again is up to the
117 /// implementation.
118 ///
119 /// Returns `true` on success or idempotent match, `false` if a conflicting
120 /// state was found (another writer won the race).
121 async fn compare_and_write(
122 &self,
123 id: &ObjectId,
124 current: Option<&ObjectId>,
125 write: TieredWrite,
126 ) -> Result<bool>;
127}
128
129/// Information about a redirect tombstone in the high-volume backend.
130#[derive(Clone, Debug, PartialEq)]
131pub struct Tombstone {
132 /// The [`ObjectId`] of the object in the long-term backend.
133 ///
134 /// For legacy tombstones with an empty `r` column, the HV backend resolves
135 /// this to the HV `ObjectId` itself before surfacing the tombstone to callers.
136 pub target: ObjectId,
137
138 /// The expiration policy copied from the original object.
139 pub expiration_policy: ExpirationPolicy,
140}
141
142/// Typed response from [`HighVolumeBackend::get_tiered_object`].
143pub enum TieredGet {
144 /// A real object was found.
145 Object(Metadata, PayloadStream),
146 /// A redirect tombstone was found; the real object lives in the long-term backend.
147 Tombstone(Tombstone),
148 /// No entry exists at this key.
149 NotFound,
150}
151
152impl fmt::Debug for TieredGet {
153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154 match self {
155 TieredGet::Object(metadata, _stream) => f
156 .debug_tuple("Object")
157 .field(metadata)
158 .finish_non_exhaustive(),
159 TieredGet::Tombstone(info) => f.debug_tuple("Tombstone").field(info).finish(),
160 TieredGet::NotFound => write!(f, "NotFound"),
161 }
162 }
163}
164
165/// Typed metadata-only response from [`HighVolumeBackend::get_tiered_metadata`].
166#[derive(Debug)]
167pub enum TieredMetadata {
168 /// Metadata for a real object was found.
169 Object(Metadata),
170 /// A redirect tombstone was found; the real object lives in the long-term backend.
171 Tombstone(Tombstone),
172 /// No entry exists at this key.
173 NotFound,
174}
175
176/// The write operation performed by [`HighVolumeBackend::compare_and_write`].
177#[derive(Clone, Debug)]
178pub enum TieredWrite {
179 /// Write a redirect tombstone.
180 Tombstone(Tombstone),
181 /// Write inline object data.
182 Object(Metadata, Bytes),
183 /// Delete the row entirely.
184 Delete,
185}
186
187impl TieredWrite {
188 /// Returns the tombstone target if this is a tombstone write, or `None` otherwise.
189 pub fn target(&self) -> Option<&ObjectId> {
190 match self {
191 TieredWrite::Tombstone(t) => Some(&t.target),
192 _ => None,
193 }
194 }
195}
196
197/// Creates a reqwest client with required defaults.
198///
199/// Automatic decompression is disabled because backends store pre-compressed
200/// payloads and manage `Content-Encoding` themselves.
201pub(super) fn reqwest_client() -> reqwest::Client {
202 reqwest::Client::builder()
203 .user_agent(USER_AGENT)
204 .hickory_dns(true)
205 .no_zstd()
206 .no_brotli()
207 .no_gzip()
208 .no_deflate()
209 .build()
210 .expect("Client::new()")
211}