1use 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#[derive(Debug)]
41pub struct Insert {
42 pub key: Option<ObjectKey>,
44 pub metadata: Metadata,
46 pub payload: bytes::Bytes,
48}
49
50#[derive(Debug)]
52pub struct Get {
53 pub key: ObjectKey,
55}
56
57#[derive(Debug)]
59pub struct Delete {
60 pub key: ObjectKey,
62}
63
64#[derive(Debug)]
66pub struct Head {
67 pub key: ObjectKey,
69}
70
71#[derive(Debug)]
73pub enum Operation {
74 Insert(Box<Insert>),
76 Get(Get),
78 Delete(Delete),
80 Head(Head),
82}
83
84impl Operation {
85 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 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 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
117pub enum OpResponse {
122 Inserted {
124 id: ObjectId,
126 },
127 Got {
129 key: ObjectKey,
131 response: GetResponse,
133 },
134 Deleted {
136 key: ObjectKey,
138 },
139 Head {
141 key: ObjectKey,
143 metadata: Option<Metadata>,
145 },
146}
147
148impl OpResponse {
149 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 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#[derive(Debug)]
208pub struct StreamExecutor {
209 backend: Arc<dyn Backend>,
210 concurrency: ConcurrencyLimiter,
211 renewals: RenewalScheduler,
212}
213
214impl StreamExecutor {
215 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 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(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 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 #[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 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 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 for _ in 0..10 {
662 paused_rx.recv().await.unwrap();
663 }
664 assert_eq!(in_flight.load(Ordering::SeqCst), 10);
665
666 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 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}