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