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