objectstore_service/change_stream/mod.rs
1//! The change stream each storage backend publishes.
2//!
3//! A backend describes its cost-tracking reporting with a [`CostTrackerStreamConfig`];
4//! the service describes where those records go with a [`CostTrackerConfig`], shared by
5//! every backend. [`ChangeStreamFactory`] pairs the two into a [`ChangeStream`].
6//!
7//! Behind the `storage-cogs` feature. Without it every backend gets a [`NoopStream`] and
8//! the transport is left out of the binary.
9
10use std::fmt;
11use std::sync::Arc;
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15
16use objectstore_types::time::Timestamp;
17
18use crate::id::ObjectId;
19
20#[cfg(feature = "storage-cogs")]
21mod cost_tracker;
22mod factory;
23
24#[cfg(feature = "storage-cogs")]
25pub use cost_tracker::CostTrackerStream;
26pub use factory::ChangeStreamFactory;
27#[cfg(feature = "storage-cogs")]
28pub use factory::CostTrackerConfig;
29
30#[cfg(all(test, feature = "storage-cogs"))]
31pub(crate) use factory::dummy_factory;
32
33/// How long a backend waits for reported records to be handed off during shutdown.
34pub const FLUSH_TIMEOUT: Duration = Duration::from_secs(2);
35
36/// Scope key holding the Sentry organization ID.
37#[cfg(feature = "storage-cogs")]
38const SCOPE_ORGANIZATION: &str = "org";
39/// Scope key holding the Sentry project ID.
40#[cfg(feature = "storage-cogs")]
41const SCOPE_PROJECT: &str = "project";
42
43/// What a single backend reports for cost tracking, and how much of it.
44///
45/// A backend without one reports nothing.
46///
47/// # Example
48///
49/// ```yaml
50/// storage_cogs:
51/// shared_resource_id: bigtable_objectstore
52/// sample_rate: 1.0
53/// ```
54#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct CostTrackerStreamConfig {
56 /// Identifies the storage backend resource.
57 ///
58 /// This is meant to correspond to a `shared_resource_id` label on a provisioned
59 /// storage resource so that change stream data can be joined with other data about
60 /// the storage resource.
61 pub shared_resource_id: String,
62
63 /// Proportion of records to report, in `[0, 1]`.
64 ///
65 /// `1.0` reports every change. It can be lowered if the stream is under too much load
66 /// but beware: when the sample rate decreases, records that used to be tracked will
67 /// no longer be tracked. Stream consumers may have inconsistent state for them until
68 /// they expire.
69 #[serde(default = "default_sample_rate")]
70 pub sample_rate: f64,
71}
72
73/// Reports everything by default.
74fn default_sample_rate() -> f64 {
75 1.0
76}
77
78/// Publishes the changes a single backend makes to the objects it stores.
79///
80/// Backends call `begin_*` before a storage operation that creates data or extends its
81/// lifetime, and `commit_*` after a storage operation succeeds. A failed `begin_*` fails
82/// the request before storage is touched. A `begin_*` is not always followed by a
83/// `commit_*` (e.g. when the storage operation fails), so consumers that must never
84/// underestimate what is stored should treat `begin_*` as an upper bound.
85///
86/// All methods but [`join`](Self::join) default to doing nothing. Backends await them
87/// on the request path, so they add to request latency.
88///
89/// Consider spawning and independent task or using a queue in your implementation of the
90/// methods if you only need best-effort delivery.
91///
92/// See [module docs](self).
93#[async_trait::async_trait]
94pub trait ChangeStream: fmt::Debug + Send + Sync + 'static {
95 /// Announces that `id` is about to be written. Used for new writes and overwrites.
96 async fn begin_write(
97 &self,
98 id: &ObjectId,
99 expires_at: Option<Timestamp>,
100 ) -> Result<(), ChangeStreamError> {
101 let _ = (id, expires_at);
102 Ok(())
103 }
104
105 /// Reports that `id` now occupies `size` bytes. Used for new writes and overwrites.
106 async fn commit_write(&self, id: &ObjectId, size: u64, expires_at: Option<Timestamp>) {
107 let _ = (id, size, expires_at);
108 }
109
110 /// Announces that `id`'s expiration is about to move out to `expires_at`.
111 async fn begin_update(
112 &self,
113 id: &ObjectId,
114 expires_at: Option<Timestamp>,
115 ) -> Result<(), ChangeStreamError> {
116 let _ = (id, expires_at);
117 Ok(())
118 }
119
120 /// Reports that `id`'s expiration moved, with its stored size unchanged.
121 async fn commit_update(&self, id: &ObjectId, expires_at: Option<Timestamp>) {
122 let _ = (id, expires_at);
123 }
124
125 /// Reports that `id` was deleted explicitly. Does not account for automatic GC.
126 async fn commit_delete(&self, id: &ObjectId) {
127 let _ = id;
128 }
129
130 /// Blocks until reported records have been delivered, or `timeout` elapses.
131 ///
132 /// Call this during shutdown to drain the change stream queue.
133 /// Waits for reported records to be delivered, or until `timeout` elapses.
134 ///
135 /// Awaited from [`Backend::join`](crate::backend::common::Backend::join) so records
136 /// reported just before shutdown are not lost.
137 async fn join(&self, timeout: Duration);
138}
139
140/// Why a [`ChangeStream`] could not begin a change.
141///
142/// The variant determines the [`ErrorKind`](crate::error::ErrorKind) of the failed
143/// request. The source carries the details and can be a plain message:
144///
145/// ```
146/// use objectstore_service::change_stream::ChangeStreamError;
147///
148/// let error = ChangeStreamError::Failure("constraint violated".into());
149/// assert_eq!(error.to_string(), "change stream failed");
150/// ```
151#[derive(Debug, thiserror::Error)]
152pub enum ChangeStreamError {
153 /// The sink is temporarily unavailable.
154 #[error("change stream unavailable")]
155 Unavailable(#[source] Box<dyn std::error::Error + Send + Sync>),
156 /// The sink did not respond in time.
157 #[error("change stream timed out")]
158 Timeout(#[source] Box<dyn std::error::Error + Send + Sync>),
159 /// Any other failure.
160 #[error("change stream failed")]
161 Failure(#[source] Box<dyn std::error::Error + Send + Sync>),
162}
163
164/// Drains `change_stream`, bounded by [`FLUSH_TIMEOUT`].
165///
166/// Backends call this from [`Backend::join`](crate::backend::common::Backend::join) so
167/// records reported just before shutdown are not silently lost.
168pub async fn flush_change_stream(change_stream: &Arc<dyn ChangeStream>) {
169 change_stream.join(FLUSH_TIMEOUT).await;
170}
171
172/// A [`ChangeStream`] that reports nothing.
173#[derive(Clone, Copy, Debug, Default)]
174pub struct NoopStream;
175
176#[async_trait::async_trait]
177impl ChangeStream for NoopStream {
178 async fn join(&self, _timeout: Duration) {}
179}
180
181/// Reads a scope value off `id` as an integer, if present and well-formed.
182#[cfg(feature = "storage-cogs")]
183fn scope_id(id: &ObjectId, scope: &str) -> Option<u64> {
184 id.scopes().get_value(scope)?.parse().ok()
185}