Skip to main content

objectstore_service/
streaming.rs

1//! Streaming operation types and concurrent executor.
2//!
3//! [`StreamExecutor`] processes a stream of `(idx, Result<`[`Operation`]`, E>)` tuples concurrently
4//! within a bounded window. Errors in the input stream pass through unchanged; successful
5//! operations are executed against the backend directly, with [`tokio::spawn`] for panic isolation
6//! and run-to-completion guarantees.
7//!
8//! ## Permit Acquisition
9//!
10//! Streaming / batch operations are subject to the service's concurrency limiter. They count as
11//! "bulk" operations, which are capped at a lower limit than regular operations. This ensures a
12//! large bulk request doesn't automatically bring the service to its limits and leaves room for
13//! regular requests.
14//!
15//! The regular acquire timeout applies: Operations that cannot acquire a permit within the
16//! configured queue timeout fail with [`Error::AtCapacity`].
17//!
18//! ## Concurrency Model
19//!
20//! [`StreamExecutor::execute`] uses `buffer_unordered` with the bulk budget as the concurrency
21//! bound. The input stream is pulled lazily and results are yielded in completion order. Each
22//! operation is wrapped in a [`tokio::spawn`] for panic isolation: a panic in one operation
23//! surfaces as [`Error::Panic`] for that item and does not affect the others.
24
25use std::sync::Arc;
26
27use futures_util::{Stream, StreamExt};
28use objectstore_types::metadata::Metadata;
29
30use crate::backend::common::Backend;
31use crate::concurrency::ConcurrencyLimiter;
32use crate::error::{Error, Result};
33use crate::id::{ObjectContext, ObjectId, ObjectKey};
34use crate::service::GetResponse;
35
36/// An insert operation: stores an object at the given key.
37#[derive(Debug)]
38pub struct Insert {
39    /// The key to store the object under. When `None`, the service generates a key.
40    pub key: Option<ObjectKey>,
41    /// Metadata for the object.
42    pub metadata: Metadata,
43    /// The object payload. Batch inserts are fully buffered (≤1 MiB).
44    pub payload: bytes::Bytes,
45}
46
47/// A get operation: retrieves an existing object by key.
48#[derive(Debug)]
49pub struct Get {
50    /// The key of the object to retrieve.
51    pub key: ObjectKey,
52}
53
54/// A delete operation: removes an object by key.
55#[derive(Debug)]
56pub struct Delete {
57    /// The key of the object to delete.
58    pub key: ObjectKey,
59}
60
61/// A head (metadata-only) operation: checks existence and retrieves metadata by key.
62#[derive(Debug)]
63pub struct Head {
64    /// The key of the object to check.
65    pub key: ObjectKey,
66}
67
68/// A single streaming operation.
69#[derive(Debug)]
70pub enum Operation {
71    /// Insert a new object.
72    Insert(Box<Insert>),
73    /// Get an existing object.
74    Get(Get),
75    /// Delete an object.
76    Delete(Delete),
77    /// Head (metadata-only) check for an object.
78    Head(Head),
79}
80
81impl Operation {
82    /// Returns the key for this operation, if one was provided.
83    pub fn key(&self) -> Option<&ObjectKey> {
84        match self {
85            Operation::Insert(op) => op.key.as_ref(),
86            Operation::Get(op) => Some(&op.key),
87            Operation::Delete(op) => Some(&op.key),
88            Operation::Head(op) => Some(&op.key),
89        }
90    }
91
92    /// Returns the permission required to perform this operation.
93    pub fn permission(&self) -> objectstore_types::auth::Permission {
94        match self {
95            Operation::Get(_) | Operation::Head(_) => {
96                objectstore_types::auth::Permission::ObjectRead
97            }
98            Operation::Insert(_) => objectstore_types::auth::Permission::ObjectWrite,
99            Operation::Delete(_) => objectstore_types::auth::Permission::ObjectDelete,
100        }
101    }
102
103    /// Returns the kind name for this operation.
104    pub fn kind(&self) -> &'static str {
105        match self {
106            Operation::Insert(_) => "insert",
107            Operation::Get(_) => "get",
108            Operation::Delete(_) => "delete",
109            Operation::Head(_) => "head",
110        }
111    }
112}
113
114/// The response of a single executed streaming operation.
115///
116/// Each variant carries the fields needed to render a response part.
117/// The kind (`"insert"`, `"get"`, `"delete"`) is derivable via [`OpResponse::kind`].
118pub enum OpResponse {
119    /// An insert completed successfully.
120    Inserted {
121        /// The fully-qualified identifier assigned to the inserted object.
122        id: ObjectId,
123    },
124    /// A get completed.
125    Got {
126        /// The key that was looked up.
127        key: ObjectKey,
128        /// The object content, or `None` if the object was not found.
129        response: GetResponse,
130    },
131    /// A delete completed successfully.
132    Deleted {
133        /// The key that was deleted.
134        key: ObjectKey,
135    },
136    /// A head (metadata-only) check completed.
137    Head {
138        /// The key that was checked.
139        key: ObjectKey,
140        /// The metadata, or `None` if the object was not found.
141        metadata: Option<Metadata>,
142    },
143}
144
145impl OpResponse {
146    /// Returns the operation kind name.
147    pub fn kind(&self) -> &'static str {
148        match self {
149            OpResponse::Inserted { .. } => "insert",
150            OpResponse::Got { .. } => "get",
151            OpResponse::Deleted { .. } => "delete",
152            OpResponse::Head { .. } => "head",
153        }
154    }
155
156    /// Returns the object key for this response.
157    pub fn key(&self) -> &ObjectKey {
158        match self {
159            OpResponse::Inserted { id } => &id.key,
160            OpResponse::Got { key, .. } => key,
161            OpResponse::Deleted { key } => key,
162            OpResponse::Head { key, .. } => key,
163        }
164    }
165}
166
167impl std::fmt::Debug for OpResponse {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        match self {
170            OpResponse::Inserted { id } => f.debug_struct("Inserted").field("id", id).finish(),
171            OpResponse::Got {
172                key,
173                response: Some(_),
174            } => f
175                .debug_struct("Got")
176                .field("key", key)
177                .field("response", &format_args!("Some(<stream>)"))
178                .finish(),
179            OpResponse::Got {
180                key,
181                response: None,
182            } => f
183                .debug_struct("Got")
184                .field("key", key)
185                .field("response", &format_args!("None"))
186                .finish(),
187            OpResponse::Deleted { key } => f.debug_struct("Deleted").field("key", key).finish(),
188            OpResponse::Head { key, metadata } => f
189                .debug_struct("Head")
190                .field("key", key)
191                .field("metadata", &metadata.is_some())
192                .finish(),
193        }
194    }
195}
196
197/// Executes streaming operations with bounded concurrency.
198///
199/// Construct via [`StorageService::stream`](crate::service::StorageService::stream).
200/// Each operation acquires a bulk permit individually; aggregate bulk
201/// concurrency is bounded by the bulk semaphore on the limiter.
202///
203/// See the [module documentation](self) for the concurrency model.
204#[derive(Debug)]
205pub struct StreamExecutor {
206    backend: Arc<dyn Backend>,
207    concurrency: ConcurrencyLimiter,
208}
209
210impl StreamExecutor {
211    /// Creates a new `StreamExecutor` with the given backend and limiter.
212    pub fn new(backend: Arc<dyn Backend>, concurrency: ConcurrencyLimiter) -> Self {
213        Self {
214            backend,
215            concurrency,
216        }
217    }
218
219    /// Executes the operations stream with bounded concurrency.
220    ///
221    /// Each item is a `(index, Result<Operation, E>)` tuple where `index` is the
222    /// 0-based position of the operation in the original request. Error items pass
223    /// through immediately; successful items acquire a bulk permit and execute
224    /// in an isolated [`tokio::spawn`].
225    ///
226    /// Permit acquisition is sequential — only one acquire is in flight at a
227    /// time, ensuring fairness with other streams and preventing a large
228    /// batch from racing for all permits at once. Execution of operations
229    /// that already hold a permit proceeds concurrently.
230    ///
231    /// Operations that cannot acquire a permit within the configured queue
232    /// timeout fail with [`Error::AtCapacity`]. Results are yielded in
233    /// completion order (not submission order).
234    pub fn execute<E>(
235        self,
236        context: ObjectContext,
237        operations: impl Stream<Item = (usize, Result<Operation, E>)> + Send + 'static,
238    ) -> impl Stream<Item = (usize, Result<OpResponse, E>)> + Send + 'static
239    where
240        E: From<Error> + Send + 'static,
241    {
242        let StreamExecutor {
243            backend,
244            concurrency,
245        } = self;
246
247        let buffer = concurrency.total_bulk().max(1) as usize;
248
249        operations
250            // `then` awaits each closure before pulling the next item, making
251            // permit acquisition sequential. this ensures fairness with other
252            // streams and prevents a large batch from racing for all permits
253            // at once.
254            .then(move |(idx, item)| {
255                let concurrency = concurrency.clone();
256                async move {
257                    let op = match item {
258                        Ok(op) => op,
259                        Err(e) => return (idx, Err(e)),
260                    };
261                    match concurrency.acquire_bulk().await {
262                        Ok(permit) => (idx, Ok((op, permit))),
263                        Err(e) => {
264                            objectstore_metrics::count!(
265                                "service.concurrency.rejected",
266                                class = "bulk"
267                            );
268                            objectstore_log::warn!("Bulk operation rejected: service at capacity");
269                            (idx, Err(E::from(e)))
270                        }
271                    }
272                }
273            })
274            .map(move |(idx, result)| {
275                let backend = Arc::clone(&backend);
276                let context = context.clone();
277                async move {
278                    let (op, permit) = match result {
279                        Ok(pair) => pair,
280                        Err(e) => return (idx, Err(e)),
281                    };
282
283                    let spawn = crate::concurrency::spawn_metered(op.kind(), permit, {
284                        execute_operation(backend, context, op)
285                    });
286                    (idx, spawn.await.map_err(E::from))
287                }
288            })
289            .buffer_unordered(buffer)
290    }
291}
292
293async fn execute_operation(
294    backend: Arc<dyn Backend>,
295    context: ObjectContext,
296    op: Operation,
297) -> Result<OpResponse> {
298    match op {
299        Operation::Get(get) => {
300            let id = ObjectId::new(context, get.key);
301            let response = backend.get_object(&id, None).await?;
302            Ok(OpResponse::Got {
303                key: id.key,
304                response,
305            })
306        }
307        Operation::Insert(insert) => {
308            let id = ObjectId::optional(context, insert.key);
309            let stream = crate::stream::single(insert.payload);
310            backend.put_object(&id, &insert.metadata, stream).await?;
311            Ok(OpResponse::Inserted { id })
312        }
313        Operation::Delete(delete) => {
314            let id = ObjectId::new(context, delete.key);
315            backend.delete_object(&id).await?;
316            Ok(OpResponse::Deleted { key: id.key })
317        }
318        Operation::Head(head) => {
319            let id = ObjectId::new(context, head.key);
320            let metadata = backend.get_metadata(&id).await?;
321            Ok(OpResponse::Head {
322                key: id.key,
323                metadata,
324            })
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use std::sync::Arc;
332    use std::sync::atomic::{AtomicUsize, Ordering};
333    use std::time::Duration;
334
335    use bytes::Bytes;
336    use futures_util::StreamExt;
337    use objectstore_types::metadata::Metadata;
338    use objectstore_types::scope::{Scope, Scopes};
339
340    use super::*;
341    use crate::backend::common::PutResponse;
342    use crate::backend::in_memory::InMemoryBackend;
343    use crate::backend::testing::{Hooks, TestBackend};
344    use crate::concurrency::ConcurrencyLimiter;
345    use crate::error::Error;
346    use crate::service::StorageService;
347    use crate::stream::{self, ClientStream};
348
349    fn make_context() -> ObjectContext {
350        ObjectContext {
351            usecase: "testing".into(),
352            scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
353        }
354    }
355
356    fn make_service_with_limit(limit: u32) -> StorageService {
357        StorageService::new(Box::new(InMemoryBackend::new("in-memory")))
358            .with_concurrency(ConcurrencyLimiter::new(limit))
359    }
360
361    fn make_service() -> StorageService {
362        make_service_with_limit(500)
363    }
364
365    // Wraps a plain `Vec<Operation>` as an indexed `Ok`-stream for `execute`.
366    fn indexed_ok(ops: Vec<Operation>) -> impl Stream<Item = (usize, Result<Operation, Error>)> {
367        futures_util::stream::iter(ops.into_iter().enumerate().map(|(i, op)| (i, Ok(op))))
368    }
369
370    // --- StreamExecutor correctness tests ---
371
372    #[tokio::test]
373    async fn execute_empty_stream() {
374        let service = make_service();
375        let executor = service.stream();
376        let outcomes: Vec<_> = executor
377            .execute(
378                make_context(),
379                futures_util::stream::empty::<(usize, Result<Operation, Error>)>(),
380            )
381            .collect()
382            .await;
383        assert!(outcomes.is_empty());
384    }
385
386    #[tokio::test]
387    async fn execute_runs_all_operations() {
388        let service = make_service();
389        let context = make_context();
390
391        // Seed an object to retrieve and delete.
392        service
393            .insert_object(
394                context.clone(),
395                Some("key1".into()),
396                Metadata::default(),
397                stream::single("hello"),
398            )
399            .await
400            .unwrap();
401
402        let ops = vec![
403            Operation::Get(Get { key: "key1".into() }),
404            Operation::Get(Get {
405                key: "nonexistent".into(),
406            }),
407            Operation::Insert(Box::new(Insert {
408                key: Some("key2".into()),
409                metadata: Metadata::default(),
410                payload: Bytes::from("world"),
411            })),
412            Operation::Delete(Delete { key: "key1".into() }),
413        ];
414
415        let executor = service.stream();
416        let outcomes: Vec<_> = executor.execute(context, indexed_ok(ops)).collect().await;
417
418        assert_eq!(outcomes.len(), 4);
419
420        for (_, result) in &outcomes {
421            let response = result
422                .as_ref()
423                .unwrap_or_else(|e| panic!("unexpected error: {e:?}"));
424            assert!(
425                !response.key().as_str().is_empty(),
426                "response must have a non-empty key"
427            );
428        }
429    }
430
431    #[tokio::test]
432    async fn execute_head_operation() {
433        let service = make_service();
434        let context = make_context();
435
436        service
437            .insert_object(
438                context.clone(),
439                Some("exists".into()),
440                Metadata::default(),
441                stream::single("data"),
442            )
443            .await
444            .unwrap();
445
446        let ops = vec![
447            Operation::Head(Head {
448                key: "exists".into(),
449            }),
450            Operation::Head(Head {
451                key: "missing".into(),
452            }),
453        ];
454
455        let executor = service.stream();
456        let mut outcomes: Vec<_> = executor.execute(context, indexed_ok(ops)).collect().await;
457        outcomes.sort_by_key(|(idx, _)| *idx);
458
459        assert_eq!(outcomes.len(), 2);
460
461        match &outcomes[0].1 {
462            Ok(OpResponse::Head {
463                key,
464                metadata: Some(_),
465            }) => assert_eq!(key.as_str(), "exists"),
466            other => panic!("expected Head with metadata, got: {other:?}"),
467        }
468
469        match &outcomes[1].1 {
470            Ok(OpResponse::Head {
471                key,
472                metadata: None,
473            }) => assert_eq!(key.as_str(), "missing"),
474            other => panic!("expected Head with None, got: {other:?}"),
475        }
476    }
477
478    // --- Service-level concurrent execution and capacity tests ---
479
480    struct GateOnPut {
481        paused_tx: tokio::sync::mpsc::Sender<()>,
482        resume: Arc<tokio::sync::Notify>,
483        in_flight: Arc<AtomicUsize>,
484    }
485
486    impl std::fmt::Debug for GateOnPut {
487        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
488            f.debug_struct("GateOnPut").finish()
489        }
490    }
491
492    #[async_trait::async_trait]
493    impl Hooks for GateOnPut {
494        async fn put_object(
495            &self,
496            inner: &InMemoryBackend,
497            id: &ObjectId,
498            metadata: &Metadata,
499            stream: ClientStream,
500        ) -> Result<PutResponse> {
501            self.in_flight.fetch_add(1, Ordering::SeqCst);
502            let _ = self.paused_tx.send(()).await;
503            self.resume.notified().await;
504            let result = inner.put_object(id, metadata, stream).await;
505            self.in_flight.fetch_sub(1, Ordering::SeqCst);
506            result
507        }
508    }
509
510    #[tokio::test]
511    async fn concurrent_execution() {
512        let (paused_tx, mut paused_rx) = tokio::sync::mpsc::channel::<()>(20);
513        let resume = Arc::new(tokio::sync::Notify::new());
514        let in_flight = Arc::new(AtomicUsize::new(0));
515
516        let gated = TestBackend::new(GateOnPut {
517            paused_tx,
518            resume: Arc::clone(&resume),
519            in_flight: Arc::clone(&in_flight),
520        });
521        let service =
522            StorageService::new(Box::new(gated)).with_concurrency(ConcurrencyLimiter::new(100));
523
524        let ops: Vec<Operation> = (0..10)
525            .map(|i| {
526                Operation::Insert(Box::new(Insert {
527                    key: Some(format!("key{i}")),
528                    metadata: Metadata::default(),
529                    payload: Bytes::from(format!("data{i}")),
530                }))
531            })
532            .collect();
533
534        let executor = service.stream();
535        let exec_handle = tokio::spawn(async move {
536            executor
537                .execute(make_context(), indexed_ok(ops))
538                .collect::<Vec<_>>()
539                .await
540        });
541
542        // Wait for all 10 operations to pause inside the backend.
543        for _ in 0..10 {
544            paused_rx.recv().await.unwrap();
545        }
546        assert_eq!(in_flight.load(Ordering::SeqCst), 10);
547
548        // Release all.
549        resume.notify_waiters();
550
551        let outcomes = exec_handle.await.unwrap();
552        assert_eq!(outcomes.len(), 10);
553        for (_, result) in &outcomes {
554            assert!(
555                matches!(result, Ok(OpResponse::Inserted { .. })),
556                "unexpected result: {result:?}",
557            );
558        }
559    }
560
561    #[tokio::test]
562    async fn bulk_respects_budget() {
563        // Bulk budget = 1 (100% of max=1). Hold the permit via a normal
564        // acquire; the bulk op should wait and eventually time out.
565        let service = StorageService::new(Box::new(InMemoryBackend::new("in-memory")))
566            .with_concurrency(
567                ConcurrencyLimiter::new(1)
568                    .with_queue(0)
569                    .with_timeout(Duration::from_millis(1))
570                    .with_bulk(100),
571            );
572
573        let _held = service.concurrency_limiter().acquire().await.unwrap();
574
575        let ops = vec![Operation::Insert(Box::new(Insert {
576            key: Some("blocked".into()),
577            metadata: Metadata::default(),
578            payload: Bytes::from("data"),
579        }))];
580
581        let executor = service.stream();
582        let outcomes: Vec<_> = executor
583            .execute(make_context(), indexed_ok(ops))
584            .collect()
585            .await;
586
587        assert_eq!(outcomes.len(), 1);
588        assert!(
589            matches!(&outcomes[0].1, Err(Error::AtCapacity)),
590            "expected AtCapacity, got {:?}",
591            outcomes[0].1,
592        );
593    }
594}