Skip to main content

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}