Skip to main content

objectstore_service/backend/
counting.rs

1//! Defines [`CountingBackend`], a decorator for the [`Backend`] trait that emits Cost of Goods Sold
2//! (COGS) compute usage metrics to the `cogs.usage` counter.
3//!
4//! [`CountingBackend`] is meant to wrap the outer-most [`Backend`] implementation owned by
5//! [`StorageService`] so that every tracked backend operation, whether a single-object operation
6//! called by [`StorageService`] or a batched operation streamed by [`StreamExecutor`], is counted
7//! once. Notably, any operation that fails before it gets to [`StorageService`] (e.g. an auth or
8//! rate limit failure at a higher layer) is not counted.
9//!
10//! For COGS purposes we use operation count as a proxy for compute cost under the assumption that
11//! each request we serve has a basically flat CPU cost. Large payloads take longer, but they can be
12//! streamed in the background while other requests are served so they don't really cost more.
13//!
14//! [`StorageService`]: crate::service::StorageService
15//! [`StreamExecutor`]: crate::streaming::StreamExecutor
16
17use std::num::NonZeroU64;
18use std::sync::Arc;
19
20use objectstore_types::metadata::Metadata;
21use objectstore_types::range::ByteRange;
22use objectstore_types::resumable::UploadProgress;
23use objectstore_types::time::Timestamp;
24
25use crate::backend::common::{
26    Backend, DeleteResponse, GetResponse, MetadataResponse, MultipartUploadBackend, PutResponse,
27};
28use crate::error::Result;
29use crate::id::ObjectId;
30use crate::multipart::{
31    AbortMultipartResponse, CompleteMultipartResponse, CompletedPart, InitiateMultipartResponse,
32    ListPartsResponse, PartNumber, UploadId, UploadPartResponse,
33};
34use crate::resumable::BackendToken;
35use crate::stream::ClientStream;
36
37/// Increments `cogs.usage` by one operation for the given `usecase`.
38///
39/// Under the hood, the `usecase` is used as the `app_feature`. This allows to identify distinct
40/// products and map them in the for the COGs pipeline.
41fn count(usecase: &str) {
42    objectstore_metrics::count!("cogs.usage" += 1, app_feature = usecase.to_owned());
43}
44
45/// A [`Backend`] decorator that counts each operation performed for COGS. Also implements
46/// [`MultipartUploadBackend`]. See the [module documentation](self) for how it should be used.
47///
48/// [`CountingBackend`]'s implementation clashes with how the [`MultipartUploadBackend`] trait is
49/// connected to the [`Backend`] trait. The workaround is to give `CountingBackend` (up to) two
50/// `Arc`s that point to the inner backend:
51/// - `inner: Arc<dyn Backend>`
52/// - `inner_multipart: Option<Arc<dyn MultipartUploadBackend>>` if `inner` supports it
53#[derive(Debug)]
54pub struct CountingBackend {
55    inner: Arc<dyn Backend>,
56}
57
58impl CountingBackend {
59    /// Creates a [`CountingBackend`] that wraps `inner` and increments `cogs.usage`
60    /// before delegating operations to it.
61    pub fn new(inner: Box<dyn Backend>) -> Self {
62        let inner: Arc<dyn Backend> = Arc::from(inner);
63        Self { inner }
64    }
65}
66
67#[async_trait::async_trait]
68impl Backend for CountingBackend {
69    fn name(&self) -> &'static str {
70        self.inner.name()
71    }
72
73    async fn put_object(
74        &self,
75        id: &ObjectId,
76        metadata: &Metadata,
77        stream: ClientStream,
78        access_time: Timestamp,
79    ) -> Result<PutResponse> {
80        count(&id.context.usecase);
81        self.inner
82            .put_object(id, metadata, stream, access_time)
83            .await
84    }
85
86    async fn get_object(
87        &self,
88        id: &ObjectId,
89        access_time: Timestamp,
90        range: Option<ByteRange>,
91    ) -> Result<GetResponse> {
92        count(&id.context.usecase);
93        self.inner.get_object(id, access_time, range).await
94    }
95
96    async fn get_metadata(
97        &self,
98        id: &ObjectId,
99        access_time: Timestamp,
100    ) -> Result<MetadataResponse> {
101        count(&id.context.usecase);
102        self.inner.get_metadata(id, access_time).await
103    }
104
105    async fn set_expiry(
106        &self,
107        id: &ObjectId,
108        expire_at: Timestamp,
109        access_time: Timestamp,
110    ) -> Result<bool> {
111        count(&id.context.usecase);
112        self.inner.set_expiry(id, expire_at, access_time).await
113    }
114
115    async fn delete_object(&self, id: &ObjectId, access_time: Timestamp) -> Result<DeleteResponse> {
116        count(&id.context.usecase);
117        self.inner.delete_object(id, access_time).await
118    }
119
120    async fn join(&self) {
121        self.inner.join().await;
122    }
123
124    fn as_multipart_upload_backend(&self) -> Result<&dyn MultipartUploadBackend> {
125        self.inner.as_multipart_upload_backend()?;
126        Ok(self)
127    }
128
129    async fn create_upload_session(
130        &self,
131        id: &ObjectId,
132        metadata: &Metadata,
133        total_length: NonZeroU64,
134    ) -> Result<Option<BackendToken>> {
135        count(&id.context.usecase);
136        self.inner
137            .create_upload_session(id, metadata, total_length)
138            .await
139    }
140
141    async fn put_chunk(
142        &self,
143        id: &ObjectId,
144        token: &BackendToken,
145        offset: u64,
146        content_length: u64,
147        stream: ClientStream,
148    ) -> Result<UploadProgress> {
149        count(&id.context.usecase);
150        self.inner
151            .put_chunk(id, token, offset, content_length, stream)
152            .await
153    }
154
155    async fn upload_offset(&self, id: &ObjectId, token: &BackendToken) -> Result<UploadProgress> {
156        count(&id.context.usecase);
157        self.inner.upload_offset(id, token).await
158    }
159
160    async fn cancel_upload(&self, id: &ObjectId, token: &BackendToken) -> Result<()> {
161        count(&id.context.usecase);
162        self.inner.cancel_upload(id, token).await
163    }
164}
165
166#[async_trait::async_trait]
167impl MultipartUploadBackend for CountingBackend {
168    async fn initiate_multipart(
169        &self,
170        id: &ObjectId,
171        metadata: &Metadata,
172    ) -> Result<InitiateMultipartResponse> {
173        count(&id.context.usecase);
174        self.inner
175            .as_multipart_upload_backend()?
176            .initiate_multipart(id, metadata)
177            .await
178    }
179
180    async fn upload_part(
181        &self,
182        id: &ObjectId,
183        upload_id: &UploadId,
184        part_number: PartNumber,
185        content_length: u64,
186        content_md5: Option<&str>,
187        body: ClientStream,
188    ) -> Result<UploadPartResponse> {
189        count(&id.context.usecase);
190        self.inner
191            .as_multipart_upload_backend()?
192            .upload_part(
193                id,
194                upload_id,
195                part_number,
196                content_length,
197                content_md5,
198                body,
199            )
200            .await
201    }
202
203    async fn list_parts(
204        &self,
205        id: &ObjectId,
206        upload_id: &UploadId,
207        max_parts: Option<u32>,
208        part_number_marker: Option<PartNumber>,
209    ) -> Result<ListPartsResponse> {
210        count(&id.context.usecase);
211        self.inner
212            .as_multipart_upload_backend()?
213            .list_parts(id, upload_id, max_parts, part_number_marker)
214            .await
215    }
216
217    async fn abort_multipart(
218        &self,
219        id: &ObjectId,
220        upload_id: &UploadId,
221    ) -> Result<AbortMultipartResponse> {
222        count(&id.context.usecase);
223        self.inner
224            .as_multipart_upload_backend()?
225            .abort_multipart(id, upload_id)
226            .await
227    }
228
229    async fn complete_multipart(
230        &self,
231        id: &ObjectId,
232        upload_id: &UploadId,
233        parts: Vec<CompletedPart>,
234        access_time: Timestamp,
235    ) -> Result<CompleteMultipartResponse> {
236        count(&id.context.usecase);
237        self.inner
238            .as_multipart_upload_backend()?
239            .complete_multipart(id, upload_id, parts, access_time)
240            .await
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use objectstore_types::scope::{Scope, Scopes};
247
248    use super::*;
249    use crate::backend::in_memory::InMemoryBackend;
250    use crate::id::ObjectContext;
251    use crate::stream;
252
253    fn object_id(usecase: &str) -> ObjectId {
254        ObjectId::new(
255            ObjectContext {
256                usecase: usecase.into(),
257                scopes: Scopes::from_iter([Scope::create("org", "1").unwrap()]),
258            },
259            "key".into(),
260        )
261    }
262
263    /// Runs `f` on a current-thread runtime while capturing emitted metrics.
264    ///
265    /// The capturing client is thread-local, so the futures must run on the same
266    /// thread that installs it.
267    fn capture(f: impl std::future::Future<Output = ()>) -> Vec<String> {
268        objectstore_metrics::with_capturing_test_client(|| {
269            tokio::runtime::Builder::new_current_thread()
270                .enable_all()
271                .build()
272                .unwrap()
273                .block_on(f);
274        })
275    }
276
277    #[test]
278    fn counts_each_core_operation_once() {
279        let captured = capture(async {
280            let backend = CountingBackend::new(Box::new(InMemoryBackend::new("in-memory")));
281            let id = object_id("attachments");
282
283            backend
284                .put_object(
285                    &id,
286                    &Metadata::default(),
287                    stream::single("hi"),
288                    Timestamp::now(),
289                )
290                .await
291                .unwrap();
292            backend
293                .get_object(&id, Timestamp::now(), None)
294                .await
295                .unwrap();
296            backend.get_metadata(&id, Timestamp::now()).await.unwrap();
297            backend.delete_object(&id, Timestamp::now()).await.unwrap();
298        });
299
300        let cogs = captured
301            .iter()
302            .filter(|m| m.starts_with("cogs.usage"))
303            .count();
304        assert_eq!(
305            cogs, 4,
306            "expected one count per operation, captured: {captured:?}"
307        );
308        assert!(
309            captured
310                .iter()
311                .all(|m| !m.starts_with("cogs.usage")
312                    || m == "cogs.usage:+1|c|#app_feature:attachments"),
313            "captured: {captured:?}"
314        );
315    }
316
317    #[test]
318    fn counts_missing_reads_on_dispatch() {
319        let captured = capture(async {
320            let backend = CountingBackend::new(Box::new(InMemoryBackend::new("in-memory")));
321            // Nothing stored: the read returns `None` but is still billed.
322            let result = backend
323                .get_object(&object_id("attachments"), Timestamp::now(), None)
324                .await
325                .unwrap();
326            assert!(result.is_none());
327        });
328
329        assert_eq!(
330            captured
331                .iter()
332                .filter(|m| m.starts_with("cogs.usage"))
333                .count(),
334            1,
335            "captured: {captured:?}"
336        );
337    }
338
339    #[test]
340    fn new_usecase_is_app_feature() {
341        let captured = capture(async {
342            let backend = CountingBackend::new(Box::new(InMemoryBackend::new("in-memory")));
343            backend
344                .get_object(&object_id("new_usecase"), Timestamp::now(), None)
345                .await
346                .unwrap();
347        });
348
349        assert!(
350            captured
351                .iter()
352                .any(|m| m == "cogs.usage:+1|c|#app_feature:new_usecase"),
353            "captured: {captured:?}"
354        );
355    }
356
357    #[test]
358    fn counts_each_multipart_operation() {
359        let captured = capture(async {
360            let backend: Arc<dyn Backend> = Arc::new(CountingBackend::new(Box::new(
361                InMemoryBackend::new("in-memory"),
362            )));
363            let multipart = backend.as_multipart_upload_backend().unwrap();
364            let id = object_id("attachments");
365
366            let upload_id = multipart
367                .initiate_multipart(&id, &Metadata::default())
368                .await
369                .unwrap();
370            multipart
371                .upload_part(
372                    &id,
373                    &upload_id,
374                    PartNumber::new(1).unwrap(),
375                    2,
376                    None,
377                    stream::single("hi"),
378                )
379                .await
380                .unwrap();
381            multipart
382                .list_parts(&id, &upload_id, None, None)
383                .await
384                .unwrap();
385            multipart
386                .complete_multipart(&id, &upload_id, vec![], Timestamp::now())
387                .await
388                .unwrap();
389            // The upload was completed above, so aborting it is a no-op; counting
390            // happens on dispatch regardless, which is what this asserts.
391            let _ = multipart.abort_multipart(&id, &upload_id).await;
392        });
393
394        assert_eq!(
395            captured
396                .iter()
397                .filter(|m| m == &"cogs.usage:+1|c|#app_feature:attachments")
398                .count(),
399            5,
400            "expected one count per multipart operation, captured: {captured:?}"
401        );
402    }
403}