1use std::collections::{HashMap, HashSet};
2use std::fmt;
3use std::io;
4use std::pin::Pin;
5use std::sync::Arc;
6use std::task::{Context, Poll};
7
8use futures_util::{Stream, StreamExt as _};
9use multer::Field;
10use objectstore_types::headers;
11use objectstore_types::metadata::{Compression, Metadata};
12use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
13use reqwest::multipart::Part;
14
15use crate::error::Error;
16use crate::put::PutBody;
17use crate::{
18 DeleteBuilder, DeleteResponse, GetBuilder, GetResponse, HeadBuilder, HeadResponse, ObjectKey,
19 PutBuilder, PutResponse, Session, get, put,
20};
21
22const HEADER_BATCH_OPERATION_KEY: &str = "x-sn-batch-operation-key";
23const HEADER_BATCH_OPERATION_KIND: &str = "x-sn-batch-operation-kind";
24const HEADER_BATCH_OPERATION_INDEX: &str = "x-sn-batch-operation-index";
25const HEADER_BATCH_OPERATION_STATUS: &str = "x-sn-batch-operation-status";
26
27const MAX_BATCH_OPS: usize = 1000;
29
30const MAX_BATCH_PART_SIZE: u32 = 1024 * 1024; const DEFAULT_INDIVIDUAL_CONCURRENCY: usize = 5;
37
38const DEFAULT_BATCH_CONCURRENCY: usize = 3;
42
43const MAX_BATCH_BODY_SIZE: u64 = 100 * 1024 * 1024; #[derive(Debug)]
51pub struct ManyBuilder {
52 session: Session,
53 operations: Vec<BatchOperation>,
54 max_individual_concurrency: Option<usize>,
55 max_batch_concurrency: Option<usize>,
56}
57
58impl Session {
59 pub fn many(&self) -> ManyBuilder {
64 ManyBuilder {
65 session: self.clone(),
66 operations: vec![],
67 max_individual_concurrency: None,
68 max_batch_concurrency: None,
69 }
70 }
71}
72
73#[derive(Debug)]
74#[allow(clippy::large_enum_variant)]
75enum BatchOperation {
76 Get {
77 key: ObjectKey,
78 decompress: bool,
79 accept_encoding: Vec<Compression>,
80 },
81 Insert {
82 key: Option<ObjectKey>,
83 metadata: Metadata,
84 body: PutBody,
85 },
86 Delete {
87 key: ObjectKey,
88 },
89 Head {
90 key: ObjectKey,
91 },
92}
93
94impl From<GetBuilder> for BatchOperation {
95 fn from(value: GetBuilder) -> Self {
96 let GetBuilder {
97 key,
98 decompress,
99 accept_encoding,
100 session: _session,
101 } = value;
102 BatchOperation::Get {
103 key,
104 decompress,
105 accept_encoding,
106 }
107 }
108}
109
110impl From<PutBuilder> for BatchOperation {
111 fn from(value: PutBuilder) -> Self {
112 let PutBuilder {
113 key,
114 metadata,
115 body,
116 session: _session,
117 } = value;
118 BatchOperation::Insert {
119 key,
120 metadata,
121 body,
122 }
123 }
124}
125
126impl From<DeleteBuilder> for BatchOperation {
127 fn from(value: DeleteBuilder) -> Self {
128 let DeleteBuilder {
129 key,
130 session: _session,
131 } = value;
132 BatchOperation::Delete { key }
133 }
134}
135
136impl From<HeadBuilder> for BatchOperation {
137 fn from(value: HeadBuilder) -> Self {
138 let HeadBuilder {
139 key,
140 session: _session,
141 } = value;
142 BatchOperation::Head { key }
143 }
144}
145
146impl BatchOperation {
147 async fn into_part(self) -> crate::Result<Part> {
148 match self {
149 BatchOperation::Get { key, .. } => {
150 let headers = operation_headers("get", Some(&key));
151 Ok(Part::text("").headers(headers))
152 }
153 BatchOperation::Insert {
154 key,
155 metadata,
156 body,
157 } => {
158 let mut headers = operation_headers("insert", key.as_deref());
159 headers.extend(metadata.to_headers("")?);
160
161 let body = put::maybe_compress(body, metadata.compression).await?;
162 Ok(Part::stream(body).headers(headers))
163 }
164 BatchOperation::Delete { key } => {
165 let headers = operation_headers("delete", Some(&key));
166 Ok(Part::text("").headers(headers))
167 }
168 BatchOperation::Head { key } => {
169 let headers = operation_headers("head", Some(&key));
170 Ok(Part::text("").headers(headers))
171 }
172 }
173 }
174}
175
176fn operation_headers(operation: &str, key: Option<&str>) -> HeaderMap {
177 let mut headers = HeaderMap::new();
178 headers.insert(
179 HeaderName::from_static(HEADER_BATCH_OPERATION_KIND),
180 HeaderValue::from_str(operation).expect("operation kind is always a valid header value"),
181 );
182 if let Some(key) = key {
183 headers.insert(
184 HeaderName::from_static(HEADER_BATCH_OPERATION_KEY),
185 headers::encode_header_value(key),
186 );
187 }
188 headers
189}
190
191#[derive(Debug)]
193pub enum OperationResult {
194 Get(ObjectKey, Result<Option<GetResponse>, Error>),
198 Put(ObjectKey, Result<PutResponse, Error>),
200 Delete(ObjectKey, Result<DeleteResponse, Error>),
202 Head(ObjectKey, Result<HeadResponse, Error>),
206 Error(Error),
212}
213
214enum OperationContext {
216 Get {
217 key: ObjectKey,
218 decompress: bool,
219 accept_encoding: Vec<Compression>,
220 },
221 Insert {
222 key: Option<ObjectKey>,
223 },
224 Delete {
225 key: ObjectKey,
226 },
227 Head {
228 key: ObjectKey,
229 },
230}
231
232impl From<&BatchOperation> for OperationContext {
233 fn from(op: &BatchOperation) -> Self {
234 match op {
235 BatchOperation::Get {
236 key,
237 decompress,
238 accept_encoding,
239 } => OperationContext::Get {
240 key: key.clone(),
241 decompress: *decompress,
242 accept_encoding: accept_encoding.clone(),
243 },
244 BatchOperation::Insert { key, .. } => OperationContext::Insert { key: key.clone() },
245 BatchOperation::Delete { key } => OperationContext::Delete { key: key.clone() },
246 BatchOperation::Head { key } => OperationContext::Head { key: key.clone() },
247 }
248 }
249}
250
251impl OperationContext {
252 fn key(&self) -> Option<&str> {
253 match self {
254 OperationContext::Get { key, .. }
255 | OperationContext::Delete { key }
256 | OperationContext::Head { key } => Some(key),
257 OperationContext::Insert { key } => key.as_deref(),
258 }
259 }
260}
261
262#[derive(Debug)]
264enum Classified {
265 Batchable(BatchOperation, u64),
267 Individual(BatchOperation),
269 Failed(OperationResult),
271}
272
273fn error_result(ctx: OperationContext, error: Error) -> OperationResult {
275 let key = ctx.key().unwrap_or("<unknown>").to_owned();
276 match ctx {
277 OperationContext::Get { .. } => OperationResult::Get(key, Err(error)),
278 OperationContext::Insert { .. } => OperationResult::Put(key, Err(error)),
279 OperationContext::Delete { .. } => OperationResult::Delete(key, Err(error)),
280 OperationContext::Head { .. } => OperationResult::Head(key, Err(error)),
281 }
282}
283
284impl OperationResult {
285 async fn from_field(
286 field: Field<'_>,
287 context_map: &HashMap<usize, OperationContext>,
288 ) -> (Option<usize>, Self) {
289 match Self::try_from_field(field, context_map).await {
290 Ok((index, result)) => (Some(index), result),
291 Err(e) => (None, OperationResult::Error(e)),
292 }
293 }
294
295 async fn try_from_field(
296 field: Field<'_>,
297 context_map: &HashMap<usize, OperationContext>,
298 ) -> Result<(usize, Self), Error> {
299 let mut headers = field.headers().clone();
300
301 let index: usize = headers
302 .remove(HEADER_BATCH_OPERATION_INDEX)
303 .and_then(|v| v.to_str().ok().and_then(|s| s.parse().ok()))
304 .ok_or_else(|| {
305 Error::MalformedResponse(format!(
306 "missing or invalid {HEADER_BATCH_OPERATION_INDEX} header"
307 ))
308 })?;
309
310 let status: u16 = headers
311 .remove(HEADER_BATCH_OPERATION_STATUS)
312 .and_then(|v| {
313 v.to_str().ok().and_then(|s| {
314 s.split_once(' ')
317 .map(|(code, _)| code)
318 .unwrap_or(s)
319 .parse()
320 .ok()
321 })
322 })
323 .ok_or_else(|| {
324 Error::MalformedResponse(format!(
325 "missing or invalid {HEADER_BATCH_OPERATION_STATUS} header"
326 ))
327 })?;
328
329 let ctx = context_map.get(&index).ok_or_else(|| {
330 Error::MalformedResponse(format!(
331 "response references unknown operation index {index}"
332 ))
333 })?;
334
335 let key = headers
337 .remove(HEADER_BATCH_OPERATION_KEY)
338 .and_then(|v| headers::decode_header_value(&v).ok())
339 .or_else(|| ctx.key().map(str::to_owned));
340
341 let body = field.bytes().await?;
342
343 let is_error = status >= 400
344 && !(matches!(
345 ctx,
346 OperationContext::Get { .. } | OperationContext::Head { .. }
347 ) && status == 404);
348
349 let key = match key {
354 Some(key) => key,
355 None if is_error => "<unknown>".to_owned(),
356 None => {
357 return Err(Error::MalformedResponse(format!(
358 "missing or invalid {HEADER_BATCH_OPERATION_KEY} header"
359 )));
360 }
361 };
362 if is_error {
363 let message = String::from_utf8_lossy(&body).into_owned();
364 let error = Error::OperationFailure { status, message };
365
366 return Ok((
367 index,
368 match ctx {
369 OperationContext::Get { .. } => OperationResult::Get(key, Err(error)),
370 OperationContext::Insert { .. } => OperationResult::Put(key, Err(error)),
371 OperationContext::Delete { .. } => OperationResult::Delete(key, Err(error)),
372 OperationContext::Head { .. } => OperationResult::Head(key, Err(error)),
373 },
374 ));
375 }
376
377 let result = match ctx {
378 OperationContext::Get {
379 decompress,
380 accept_encoding,
381 ..
382 } => {
383 if status == 404 {
384 OperationResult::Get(key, Ok(None))
385 } else {
386 let mut metadata = Metadata::from_headers(&headers, "")?;
387
388 let stream =
389 futures_util::stream::once(async move { Ok::<_, io::Error>(body) }).boxed();
390 let stream =
391 get::maybe_decompress(stream, &mut metadata, *decompress, accept_encoding);
392
393 OperationResult::Get(key, Ok(Some(GetResponse { metadata, stream })))
394 }
395 }
396 OperationContext::Insert { .. } => {
397 OperationResult::Put(key.clone(), Ok(PutResponse { key }))
398 }
399 OperationContext::Delete { .. } => OperationResult::Delete(key, Ok(())),
400 OperationContext::Head { .. } => {
401 if status == 404 {
402 OperationResult::Head(key, Ok(None))
403 } else {
404 let metadata = Metadata::from_headers(&headers, "")?;
405 OperationResult::Head(key, Ok(Some(metadata)))
406 }
407 }
408 };
409 Ok((index, result))
410 }
411}
412
413pub struct OperationResults(Pin<Box<dyn Stream<Item = OperationResult> + Send>>);
415
416impl fmt::Debug for OperationResults {
417 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
418 f.write_str("OperationResults([Stream])")
419 }
420}
421
422impl Stream for OperationResults {
423 type Item = OperationResult;
424
425 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
426 self.0.as_mut().poll_next(cx)
427 }
428}
429
430impl OperationResults {
431 pub async fn error_for_failures(mut self) -> crate::Result<(), impl Iterator<Item = Error>> {
436 let mut errs = Vec::new();
437 while let Some(res) = self.next().await {
438 match res {
439 OperationResult::Get(_, get) => {
440 if let Err(e) = get {
441 errs.push(e);
442 }
443 }
444 OperationResult::Put(_, put) => {
445 if let Err(e) = put {
446 errs.push(e);
447 }
448 }
449 OperationResult::Delete(_, delete) => {
450 if let Err(e) = delete {
451 errs.push(e);
452 }
453 }
454 OperationResult::Head(_, head) => {
455 if let Err(e) = head {
456 errs.push(e);
457 }
458 }
459 OperationResult::Error(error) => errs.push(error),
460 }
461 }
462 if errs.is_empty() {
463 return Ok(());
464 }
465 Err(errs.into_iter())
466 }
467}
468
469async fn send_batch(
470 session: &Session,
471 operations: Vec<BatchOperation>,
472) -> crate::Result<Vec<OperationResult>> {
473 let mut context_map: HashMap<usize, OperationContext> = operations
474 .iter()
475 .enumerate()
476 .map(|(idx, op)| (idx, OperationContext::from(op)))
477 .collect();
478 let num_operations = operations.len();
479
480 let mut form = reqwest::multipart::Form::new();
481 for op in operations {
482 let part = op.into_part().await?;
483 form = form.part("part", part);
484 }
485
486 let request = session.batch_request()?.multipart(form);
487 let response = request.send().await?.error_for_status()?;
488
489 let boundary = response
490 .headers()
491 .get(CONTENT_TYPE)
492 .and_then(|v| v.to_str().ok())
493 .ok_or_else(|| Error::MalformedResponse("missing Content-Type header".to_owned()))
494 .map(multer::parse_boundary)??;
495
496 let byte_stream = response.bytes_stream().map(|r| r.map_err(io::Error::other));
497 let mut multipart = multer::Multipart::new(byte_stream, boundary);
498
499 let mut results = Vec::new();
500 let mut seen_indices = HashSet::new();
501 while let Some(field) = multipart.next_field().await? {
502 let (index, result) = OperationResult::from_field(field, &context_map).await;
503 if let Some(idx) = index {
504 seen_indices.insert(idx);
505 }
506 results.push(result);
507 }
508
509 for idx in 0..num_operations {
510 if !seen_indices.contains(&idx) {
511 let error = Error::MalformedResponse(format!(
512 "server did not return a response for operation at index {idx}"
513 ));
514 let result = match context_map.remove(&idx) {
515 Some(ctx) => error_result(ctx, error),
516 None => OperationResult::Error(error),
517 };
518 results.push(result);
519 }
520 }
521
522 Ok(results)
523}
524
525fn classify_fail(key: Option<ObjectKey>, error: Error) -> Classified {
526 Classified::Failed(OperationResult::Put(
527 key.unwrap_or_else(|| "<unknown>".to_owned()),
528 Err(error),
529 ))
530}
531
532async fn classify(op: BatchOperation) -> Classified {
537 match op {
538 BatchOperation::Insert {
539 key,
540 metadata,
541 body,
542 } => {
543 let size = match &body {
544 PutBody::Buffer(bytes) => Some(bytes.len() as u64),
545 PutBody::File(file) => match file.metadata().await {
546 Ok(meta) => Some(meta.len()),
547 Err(err) => return classify_fail(key, err.into()),
548 },
549 PutBody::Path(path) => match tokio::fs::metadata(path).await {
550 Ok(meta) => Some(meta.len()),
551 Err(err) => return classify_fail(key, err.into()),
552 },
553 PutBody::Stream(_) => None,
555 };
556
557 let size = match (metadata.compression, size) {
558 (Some(Compression::Zstd), Some(size)) => {
559 usize::try_from(size).ok().map(zstd_safe::compress_bound)
560 }
561 (None, Some(size)) => usize::try_from(size).ok(),
562 (_, None) => None,
563 };
564
565 let op = BatchOperation::Insert {
566 key,
567 metadata,
568 body,
569 };
570
571 match size {
572 Some(s) if s <= MAX_BATCH_PART_SIZE as usize => Classified::Batchable(op, s as u64),
573 _ => Classified::Individual(op),
574 }
575 }
576 other => Classified::Batchable(other, 0),
577 }
578}
579
580async fn partition(
584 operations: Vec<BatchOperation>,
585) -> (
586 Vec<(BatchOperation, u64)>,
587 Vec<BatchOperation>,
588 Vec<OperationResult>,
589) {
590 let classified = futures_util::future::join_all(operations.into_iter().map(classify)).await;
591 let mut batchable = Vec::new();
592 let mut individual = Vec::new();
593 let mut failed = Vec::new();
594 for item in classified {
595 match item {
596 Classified::Batchable(op, size) => batchable.push((op, size)),
597 Classified::Individual(op) => individual.push(op),
598 Classified::Failed(result) => failed.push(result),
599 }
600 }
601 (batchable, individual, failed)
602}
603
604async fn execute_individual(op: BatchOperation, session: &Session) -> OperationResult {
606 match op {
607 BatchOperation::Get {
608 key,
609 decompress,
610 accept_encoding,
611 } => {
612 let get = GetBuilder {
613 session: session.clone(),
614 key: key.clone(),
615 decompress,
616 accept_encoding,
617 };
618 OperationResult::Get(key, get.send().await)
619 }
620 BatchOperation::Insert {
621 key,
622 metadata,
623 body,
624 } => {
625 let error_key = key.clone().unwrap_or_else(|| "<unknown>".to_owned());
626 let put = PutBuilder {
627 session: session.clone(),
628 metadata,
629 key,
630 body,
631 };
632 match put.send().await {
633 Ok(response) => OperationResult::Put(response.key.clone(), Ok(response)),
634 Err(err) => OperationResult::Put(error_key, Err(err)),
635 }
636 }
637 BatchOperation::Delete { key } => {
638 let delete = DeleteBuilder {
639 session: session.clone(),
640 key: key.clone(),
641 };
642 OperationResult::Delete(key, delete.send().await)
643 }
644 BatchOperation::Head { key } => {
645 let head = HeadBuilder {
646 session: session.clone(),
647 key: key.clone(),
648 };
649 OperationResult::Head(key, head.send().await)
650 }
651 }
652}
653
654async fn execute_batch(operations: Vec<BatchOperation>, session: &Session) -> Vec<OperationResult> {
658 let contexts: Vec<_> = operations.iter().map(OperationContext::from).collect();
659 match send_batch(session, operations).await {
660 Ok(results) => results,
661 Err(e) => {
662 let shared = Arc::new(e);
663 contexts
664 .into_iter()
665 .map(|ctx| error_result(ctx, Error::Batch(shared.clone())))
666 .collect()
667 }
668 }
669}
670
671fn iter_batches(ops: Vec<(BatchOperation, u64)>) -> impl Iterator<Item = Vec<BatchOperation>> {
676 let mut remaining = ops.into_iter().peekable();
677
678 std::iter::from_fn(move || {
679 remaining.peek()?;
680 let mut batch_size = 0;
681 let mut batch = Vec::new();
682
683 while let Some((_, op_size)) = remaining.peek() {
684 if batch.len() >= MAX_BATCH_OPS
685 || (!batch.is_empty() && batch_size + op_size > MAX_BATCH_BODY_SIZE)
686 {
687 break;
688 }
689
690 let (op, op_size) = remaining.next().expect("peeked above");
691 batch_size += op_size;
692 batch.push(op);
693 }
694
695 Some(batch)
696 })
697}
698
699impl ManyBuilder {
700 pub async fn send(self) -> OperationResults {
704 let session = self.session;
705 let individual_concurrency = self
706 .max_individual_concurrency
707 .unwrap_or(DEFAULT_INDIVIDUAL_CONCURRENCY)
708 .max(1);
709 let batch_concurrency = self
710 .max_batch_concurrency
711 .unwrap_or(DEFAULT_BATCH_CONCURRENCY)
712 .max(1);
713
714 let (batchable, individual, failed) = partition(self.operations).await;
716
717 let individual_results = futures_util::stream::iter(individual)
719 .map({
720 let session = session.clone();
721 move |op| {
722 let session = session.clone();
723 async move { execute_individual(op, &session).await }
724 }
725 })
726 .buffer_unordered(individual_concurrency);
727
728 let batch_results = futures_util::stream::iter(iter_batches(batchable))
730 .map(move |chunk| {
731 let session = session.clone();
732 async move { execute_batch(chunk, &session).await }
733 })
734 .buffer_unordered(batch_concurrency)
735 .flat_map(futures_util::stream::iter);
736
737 let results = futures_util::stream::iter(failed)
738 .chain(individual_results)
739 .chain(batch_results);
740
741 OperationResults(results.boxed())
742 }
743
744 pub fn max_individual_concurrency(mut self, concurrency: usize) -> Self {
750 self.max_individual_concurrency = Some(concurrency);
751 self
752 }
753
754 pub fn max_batch_concurrency(mut self, concurrency: usize) -> Self {
760 self.max_batch_concurrency = Some(concurrency);
761 self
762 }
763
764 #[allow(private_bounds)]
774 pub fn push<B: Into<BatchOperation>>(mut self, builder: B) -> Self {
775 self.operations.push(builder.into());
776 self
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 fn op(size: u64) -> (BatchOperation, u64) {
786 (
787 BatchOperation::Delete {
788 key: "k".to_owned(),
789 },
790 size,
791 )
792 }
793
794 fn batch_sizes(batches: &[Vec<BatchOperation>]) -> Vec<usize> {
795 batches.iter().map(Vec::len).collect()
796 }
797
798 fn batches(ops: Vec<(BatchOperation, u64)>) -> Vec<Vec<BatchOperation>> {
799 iter_batches(ops).collect()
800 }
801
802 fn put_with_zstd(size: usize) -> BatchOperation {
803 BatchOperation::Insert {
804 key: Some("k".to_owned()),
805 metadata: Metadata {
806 compression: Some(Compression::Zstd),
807 ..Default::default()
808 },
809 body: PutBody::Buffer(vec![0; size].into()),
810 }
811 }
812
813 #[tokio::test]
814 async fn zstd_put_at_limit_is_batchable() {
815 let size = 1_044_496;
816 let post_compression = zstd_safe::compress_bound(size);
817 assert!(post_compression == MAX_BATCH_PART_SIZE as usize);
818
819 core::assert_matches!(
820 classify(put_with_zstd(size)).await,
821 Classified::Batchable(_, s) if s == post_compression as u64
822 );
823 }
824
825 #[tokio::test]
826 async fn zstd_put_above_limit_is_individual() {
827 let size = 1_044_497;
828 let post_compression = zstd_safe::compress_bound(size);
829 assert!(post_compression > MAX_BATCH_PART_SIZE as usize);
830
831 core::assert_matches!(
832 classify(put_with_zstd(size)).await,
833 Classified::Individual(_)
834 );
835 }
836
837 #[test]
838 fn iter_batches_empty() {
839 assert!(batches(vec![]).is_empty());
840 }
841
842 #[test]
843 fn iter_batches_single_batch_count_limit() {
844 let ops: Vec<_> = (0..1000).map(|_| op(1)).collect();
846 assert_eq!(batch_sizes(&batches(ops)), vec![1000]);
847 }
848
849 #[test]
850 fn iter_batches_splits_on_count_limit() {
851 let ops: Vec<_> = (0..1001).map(|_| op(1)).collect();
853 assert_eq!(batch_sizes(&batches(ops)), vec![1000, 1]);
854 }
855
856 #[test]
857 fn iter_batches_exactly_at_size_limit() {
858 let ops: Vec<_> = (0..100).map(|_| op(1024 * 1024)).collect();
860 assert_eq!(batch_sizes(&batches(ops)), vec![100]);
861 }
862
863 #[test]
864 fn iter_batches_splits_on_size_limit() {
865 let ops: Vec<_> = (0..101).map(|_| op(1024 * 1024)).collect();
867 assert_eq!(batch_sizes(&batches(ops)), vec![100, 1]);
868 }
869
870 #[test]
871 fn iter_batches_size_limit_hits_before_count_limit() {
872 let op_size = 600 * 1024;
874 let ops: Vec<_> = (0..200).map(|_| op(op_size)).collect();
875 let result = batches(ops);
876 let per_batch = (MAX_BATCH_BODY_SIZE / op_size) as usize;
878 assert!(result.len() > 1, "expected multiple batches");
879 for batch in &result[..result.len() - 1] {
880 assert_eq!(batch.len(), per_batch);
881 }
882 }
883}