Skip to main content

objectstore_client/
many.rs

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::metadata::{Compression, Metadata};
11use percent_encoding::NON_ALPHANUMERIC;
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
27/// Maximum number of operations to send in a batch request.
28const MAX_BATCH_OPS: usize = 1000;
29
30/// Maximum amount of bytes to send as a part's body in a batch request.
31const MAX_BATCH_PART_SIZE: u32 = 1024 * 1024; // 1 MB
32
33/// Default maximum number of concurrent individual (non-batch) requests.
34///
35/// Can be overridden via [`ManyBuilder::max_individual_concurrency`].
36const DEFAULT_INDIVIDUAL_CONCURRENCY: usize = 5;
37
38/// Default maximum number of concurrent batch requests.
39///
40/// Can be overridden via [`ManyBuilder::max_batch_concurrency`].
41const DEFAULT_BATCH_CONCURRENCY: usize = 3;
42
43/// Maximum total body (post-compression, estimated) size to include in a single batch request.
44const MAX_BATCH_BODY_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
45
46/// A builder that can be used to enqueue multiple operations.
47///
48/// The client can optionally execute the operations as batch requests, leading to
49/// reduced network overhead.
50#[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    /// Creates a [`ManyBuilder`] associated with this session.
60    ///
61    /// A [`ManyBuilder`] can be used to enqueue multiple operations, which the client can choose to
62    /// send as batch requests via a dedicated endpoint, minimizing network overhead.
63    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        let encoded =
184            percent_encoding::percent_encode(key.as_bytes(), NON_ALPHANUMERIC).to_string();
185        headers.insert(
186            HeaderName::from_static(HEADER_BATCH_OPERATION_KEY),
187            HeaderValue::try_from(encoded)
188                .expect("percent-encoded string is always a valid header value"),
189        );
190    }
191    headers
192}
193
194/// The result of an individual operation.
195#[derive(Debug)]
196pub enum OperationResult {
197    /// The result of a get operation.
198    ///
199    /// Returns `Ok(None)` if the object was not found.
200    Get(ObjectKey, Result<Option<GetResponse>, Error>),
201    /// The result of a put operation.
202    Put(ObjectKey, Result<PutResponse, Error>),
203    /// The result of a delete operation.
204    Delete(ObjectKey, Result<DeleteResponse, Error>),
205    /// The result of a head (metadata-only) operation.
206    ///
207    /// Returns `Ok(None)` if the object was not found.
208    Head(ObjectKey, Result<HeadResponse, Error>),
209    /// An error occurred while parsing or correlating a response part.
210    ///
211    /// This makes it impossible to attribute the error to a specific operation.
212    /// It can happen if the response contains malformed or missing headers, references
213    /// unknown operation indices, or if a network error occurs while reading a response part.
214    Error(Error),
215}
216
217/// Context for an operation, used to map a response part to a proper `OperationResult`.
218enum OperationContext {
219    Get {
220        key: ObjectKey,
221        decompress: bool,
222        accept_encoding: Vec<Compression>,
223    },
224    Insert {
225        key: Option<ObjectKey>,
226    },
227    Delete {
228        key: ObjectKey,
229    },
230    Head {
231        key: ObjectKey,
232    },
233}
234
235impl From<&BatchOperation> for OperationContext {
236    fn from(op: &BatchOperation) -> Self {
237        match op {
238            BatchOperation::Get {
239                key,
240                decompress,
241                accept_encoding,
242            } => OperationContext::Get {
243                key: key.clone(),
244                decompress: *decompress,
245                accept_encoding: accept_encoding.clone(),
246            },
247            BatchOperation::Insert { key, .. } => OperationContext::Insert { key: key.clone() },
248            BatchOperation::Delete { key } => OperationContext::Delete { key: key.clone() },
249            BatchOperation::Head { key } => OperationContext::Head { key: key.clone() },
250        }
251    }
252}
253
254impl OperationContext {
255    fn key(&self) -> Option<&str> {
256        match self {
257            OperationContext::Get { key, .. }
258            | OperationContext::Delete { key }
259            | OperationContext::Head { key } => Some(key),
260            OperationContext::Insert { key } => key.as_deref(),
261        }
262    }
263}
264
265/// The result of classifying a single operation for batch processing.
266#[derive(Debug)]
267enum Classified {
268    /// The operation can be included in a batch request, with its estimated body size in bytes.
269    Batchable(BatchOperation, u64),
270    /// The operation must be executed as an individual request (e.g., oversized file body).
271    Individual(BatchOperation),
272    /// An error was encountered during classification.
273    Failed(OperationResult),
274}
275
276/// Creates a typed error [`OperationResult`] for the given operation context.
277fn error_result(ctx: OperationContext, error: Error) -> OperationResult {
278    let key = ctx.key().unwrap_or("<unknown>").to_owned();
279    match ctx {
280        OperationContext::Get { .. } => OperationResult::Get(key, Err(error)),
281        OperationContext::Insert { .. } => OperationResult::Put(key, Err(error)),
282        OperationContext::Delete { .. } => OperationResult::Delete(key, Err(error)),
283        OperationContext::Head { .. } => OperationResult::Head(key, Err(error)),
284    }
285}
286
287impl OperationResult {
288    async fn from_field(
289        field: Field<'_>,
290        context_map: &HashMap<usize, OperationContext>,
291    ) -> (Option<usize>, Self) {
292        match Self::try_from_field(field, context_map).await {
293            Ok((index, result)) => (Some(index), result),
294            Err(e) => (None, OperationResult::Error(e)),
295        }
296    }
297
298    async fn try_from_field(
299        field: Field<'_>,
300        context_map: &HashMap<usize, OperationContext>,
301    ) -> Result<(usize, Self), Error> {
302        let mut headers = field.headers().clone();
303
304        let index: usize = headers
305            .remove(HEADER_BATCH_OPERATION_INDEX)
306            .and_then(|v| v.to_str().ok().and_then(|s| s.parse().ok()))
307            .ok_or_else(|| {
308                Error::MalformedResponse(format!(
309                    "missing or invalid {HEADER_BATCH_OPERATION_INDEX} header"
310                ))
311            })?;
312
313        let status: u16 = headers
314            .remove(HEADER_BATCH_OPERATION_STATUS)
315            .and_then(|v| {
316                v.to_str().ok().and_then(|s| {
317                    // Status header format is "code reason" (e.g., "200 OK")
318                    // Split on first space and parse the code
319                    s.split_once(' ')
320                        .map(|(code, _)| code)
321                        .unwrap_or(s)
322                        .parse()
323                        .ok()
324                })
325            })
326            .ok_or_else(|| {
327                Error::MalformedResponse(format!(
328                    "missing or invalid {HEADER_BATCH_OPERATION_STATUS} header"
329                ))
330            })?;
331
332        let ctx = context_map.get(&index).ok_or_else(|| {
333            Error::MalformedResponse(format!(
334                "response references unknown operation index {index}"
335            ))
336        })?;
337
338        // Prioritize the server-provided key, fall back to the one from context.
339        let key = headers
340            .remove(HEADER_BATCH_OPERATION_KEY)
341            .and_then(|v| {
342                v.to_str()
343                    .ok()
344                    .and_then(|encoded| {
345                        percent_encoding::percent_decode_str(encoded)
346                            .decode_utf8()
347                            .ok()
348                    })
349                    .map(|s| s.into_owned())
350            })
351            .or_else(|| ctx.key().map(str::to_owned));
352
353        let body = field.bytes().await?;
354
355        let is_error = status >= 400
356            && !(matches!(
357                ctx,
358                OperationContext::Get { .. } | OperationContext::Head { .. }
359            ) && status == 404);
360
361        // For error responses, the key may be absent (e.g., server-generated key inserts
362        // that fail before execution — the server never generated a key and the client
363        // never provided one). Use a sentinel fallback since there is no key to report.
364        // For success responses, the key is always required.
365        let key = match key {
366            Some(key) => key,
367            None if is_error => "<unknown>".to_owned(),
368            None => {
369                return Err(Error::MalformedResponse(format!(
370                    "missing or invalid {HEADER_BATCH_OPERATION_KEY} header"
371                )));
372            }
373        };
374        if is_error {
375            let message = String::from_utf8_lossy(&body).into_owned();
376            let error = Error::OperationFailure { status, message };
377
378            return Ok((
379                index,
380                match ctx {
381                    OperationContext::Get { .. } => OperationResult::Get(key, Err(error)),
382                    OperationContext::Insert { .. } => OperationResult::Put(key, Err(error)),
383                    OperationContext::Delete { .. } => OperationResult::Delete(key, Err(error)),
384                    OperationContext::Head { .. } => OperationResult::Head(key, Err(error)),
385                },
386            ));
387        }
388
389        let result = match ctx {
390            OperationContext::Get {
391                decompress,
392                accept_encoding,
393                ..
394            } => {
395                if status == 404 {
396                    OperationResult::Get(key, Ok(None))
397                } else {
398                    let mut metadata = Metadata::from_headers(&headers, "")?;
399
400                    let stream =
401                        futures_util::stream::once(async move { Ok::<_, io::Error>(body) }).boxed();
402                    let stream =
403                        get::maybe_decompress(stream, &mut metadata, *decompress, accept_encoding);
404
405                    OperationResult::Get(key, Ok(Some(GetResponse { metadata, stream })))
406                }
407            }
408            OperationContext::Insert { .. } => {
409                OperationResult::Put(key.clone(), Ok(PutResponse { key }))
410            }
411            OperationContext::Delete { .. } => OperationResult::Delete(key, Ok(())),
412            OperationContext::Head { .. } => {
413                if status == 404 {
414                    OperationResult::Head(key, Ok(None))
415                } else {
416                    let metadata = Metadata::from_headers(&headers, "")?;
417                    OperationResult::Head(key, Ok(Some(metadata)))
418                }
419            }
420        };
421        Ok((index, result))
422    }
423}
424
425/// Container for the results of all operations in a many request.
426pub struct OperationResults(Pin<Box<dyn Stream<Item = OperationResult> + Send>>);
427
428impl fmt::Debug for OperationResults {
429    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
430        f.write_str("OperationResults([Stream])")
431    }
432}
433
434impl Stream for OperationResults {
435    type Item = OperationResult;
436
437    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
438        self.0.as_mut().poll_next(cx)
439    }
440}
441
442impl OperationResults {
443    /// Drains the stream and collects any per-operation errors.
444    ///
445    /// Returns an error containing an iterator of all individual errors for the operations
446    /// that failed, if any.
447    pub async fn error_for_failures(
448        mut self,
449    ) -> crate::Result<(), impl Iterator<Item = crate::Error>> {
450        let mut errs = Vec::new();
451        while let Some(res) = self.next().await {
452            match res {
453                OperationResult::Get(_, get) => {
454                    if let Err(e) = get {
455                        errs.push(e);
456                    }
457                }
458                OperationResult::Put(_, put) => {
459                    if let Err(e) = put {
460                        errs.push(e);
461                    }
462                }
463                OperationResult::Delete(_, delete) => {
464                    if let Err(e) = delete {
465                        errs.push(e);
466                    }
467                }
468                OperationResult::Head(_, head) => {
469                    if let Err(e) = head {
470                        errs.push(e);
471                    }
472                }
473                OperationResult::Error(error) => errs.push(error),
474            }
475        }
476        if errs.is_empty() {
477            return Ok(());
478        }
479        Err(errs.into_iter())
480    }
481}
482
483async fn send_batch(
484    session: &Session,
485    operations: Vec<BatchOperation>,
486) -> crate::Result<Vec<OperationResult>> {
487    let mut context_map: HashMap<usize, OperationContext> = operations
488        .iter()
489        .enumerate()
490        .map(|(idx, op)| (idx, OperationContext::from(op)))
491        .collect();
492    let num_operations = operations.len();
493
494    let mut form = reqwest::multipart::Form::new();
495    for op in operations.into_iter() {
496        let part = op.into_part().await?;
497        form = form.part("part", part);
498    }
499
500    let request = session.batch_request()?.multipart(form);
501    let response = request.send().await?.error_for_status()?;
502
503    let boundary = response
504        .headers()
505        .get(CONTENT_TYPE)
506        .and_then(|v| v.to_str().ok())
507        .ok_or_else(|| Error::MalformedResponse("missing Content-Type header".to_owned()))
508        .map(multer::parse_boundary)??;
509
510    let byte_stream = response.bytes_stream().map(|r| r.map_err(io::Error::other));
511    let mut multipart = multer::Multipart::new(byte_stream, boundary);
512
513    let mut results = Vec::new();
514    let mut seen_indices = HashSet::new();
515    while let Some(field) = multipart.next_field().await? {
516        let (index, result) = OperationResult::from_field(field, &context_map).await;
517        if let Some(idx) = index {
518            seen_indices.insert(idx);
519        }
520        results.push(result);
521    }
522
523    for idx in 0..num_operations {
524        if !seen_indices.contains(&idx) {
525            let error = Error::MalformedResponse(format!(
526                "server did not return a response for operation at index {idx}"
527            ));
528            let result = match context_map.remove(&idx) {
529                Some(ctx) => error_result(ctx, error),
530                None => OperationResult::Error(error),
531            };
532            results.push(result);
533        }
534    }
535
536    Ok(results)
537}
538
539fn classify_fail(key: Option<ObjectKey>, error: Error) -> Classified {
540    Classified::Failed(OperationResult::Put(
541        key.unwrap_or_else(|| "<unknown>".to_owned()),
542        Err(error),
543    ))
544}
545
546/// Classifies a single operation for batch processing.
547///
548/// Insert operations whose body exceeds [`MAX_BATCH_PART_SIZE`] are marked as
549/// [`Classified::Individual`]. Everything else is [`Classified::Batchable`].
550async fn classify(op: BatchOperation) -> Classified {
551    match op {
552        BatchOperation::Insert {
553            key,
554            metadata,
555            body,
556        } => {
557            let size = match &body {
558                PutBody::Buffer(bytes) => Some(bytes.len() as u64),
559                PutBody::File(file) => match file.metadata().await {
560                    Ok(meta) => Some(meta.len()),
561                    Err(err) => return classify_fail(key, err.into()),
562                },
563                PutBody::Path(path) => match tokio::fs::metadata(path).await {
564                    Ok(meta) => Some(meta.len()),
565                    Err(err) => return classify_fail(key, err.into()),
566                },
567                // Streams have unknown size and must not go through the batch endpoint.
568                PutBody::Stream(_) => None,
569            };
570
571            let size = match (metadata.compression, size) {
572                (Some(Compression::Zstd), Some(size)) => {
573                    usize::try_from(size).ok().map(zstd_safe::compress_bound)
574                }
575                (None, Some(size)) => usize::try_from(size).ok(),
576                (_, None) => None,
577            };
578
579            let op = BatchOperation::Insert {
580                key,
581                metadata,
582                body,
583            };
584
585            match size {
586                Some(s) if s <= MAX_BATCH_PART_SIZE as usize => Classified::Batchable(op, s as u64),
587                _ => Classified::Individual(op),
588            }
589        }
590        other => Classified::Batchable(other, 0),
591    }
592}
593
594/// Classifies all operations, partitioning them into batchable, individual, and failed.
595///
596/// Classification is parallelized since it may involve FS I/O (e.g., stat calls).
597async fn partition(
598    operations: Vec<BatchOperation>,
599) -> (
600    Vec<(BatchOperation, u64)>,
601    Vec<BatchOperation>,
602    Vec<OperationResult>,
603) {
604    let classified = futures_util::future::join_all(operations.into_iter().map(classify)).await;
605    let mut batchable = Vec::new();
606    let mut individual = Vec::new();
607    let mut failed = Vec::new();
608    for item in classified {
609        match item {
610            Classified::Batchable(op, size) => batchable.push((op, size)),
611            Classified::Individual(op) => individual.push(op),
612            Classified::Failed(result) => failed.push(result),
613        }
614    }
615    (batchable, individual, failed)
616}
617
618/// Executes a single operation as an individual (non-batch) request.
619async fn execute_individual(op: BatchOperation, session: &Session) -> OperationResult {
620    match op {
621        BatchOperation::Get {
622            key,
623            decompress,
624            accept_encoding,
625        } => {
626            let get = GetBuilder {
627                session: session.clone(),
628                key: key.clone(),
629                decompress,
630                accept_encoding,
631            };
632            OperationResult::Get(key, get.send().await)
633        }
634        BatchOperation::Insert {
635            key,
636            metadata,
637            body,
638        } => {
639            let error_key = key.clone().unwrap_or_else(|| "<unknown>".to_owned());
640            let put = PutBuilder {
641                session: session.clone(),
642                metadata,
643                key,
644                body,
645            };
646            match put.send().await {
647                Ok(response) => OperationResult::Put(response.key.clone(), Ok(response)),
648                Err(err) => OperationResult::Put(error_key, Err(err)),
649            }
650        }
651        BatchOperation::Delete { key } => {
652            let delete = DeleteBuilder {
653                session: session.clone(),
654                key: key.clone(),
655            };
656            OperationResult::Delete(key, delete.send().await)
657        }
658        BatchOperation::Head { key } => {
659            let head = HeadBuilder {
660                session: session.clone(),
661                key: key.clone(),
662            };
663            OperationResult::Head(key, head.send().await)
664        }
665    }
666}
667
668/// Sends a chunk of operations as a single batch request.
669///
670/// On batch-level failure, produces per-operation error results.
671async fn execute_batch(operations: Vec<BatchOperation>, session: &Session) -> Vec<OperationResult> {
672    let contexts: Vec<_> = operations.iter().map(OperationContext::from).collect();
673    match send_batch(session, operations).await {
674        Ok(results) => results,
675        Err(e) => {
676            let shared = Arc::new(e);
677            contexts
678                .into_iter()
679                .map(|ctx| error_result(ctx, Error::Batch(shared.clone())))
680                .collect()
681        }
682    }
683}
684
685/// Returns a lazy iterator over batches of operations.
686///
687/// Each batch respects both the operation-count limit ([`MAX_BATCH_OPS`]) and the total body-size
688/// limit ([`MAX_BATCH_BODY_SIZE`]).
689fn iter_batches(ops: Vec<(BatchOperation, u64)>) -> impl Iterator<Item = Vec<BatchOperation>> {
690    let mut remaining = ops.into_iter().peekable();
691
692    std::iter::from_fn(move || {
693        remaining.peek()?;
694        let mut batch_size = 0;
695        let mut batch = Vec::new();
696
697        while let Some((_, op_size)) = remaining.peek() {
698            if batch.len() >= MAX_BATCH_OPS
699                || (!batch.is_empty() && batch_size + op_size > MAX_BATCH_BODY_SIZE)
700            {
701                break;
702            }
703
704            let (op, op_size) = remaining.next().expect("peeked above");
705            batch_size += op_size;
706            batch.push(op);
707        }
708
709        Some(batch)
710    })
711}
712
713impl ManyBuilder {
714    /// Consumes this builder, returning a lazy stream over all the enqueued operations' results.
715    ///
716    /// The results are not guaranteed to be in the order they were originally enqueued in.
717    pub async fn send(self) -> OperationResults {
718        let session = self.session;
719        let individual_concurrency = self
720            .max_individual_concurrency
721            .unwrap_or(DEFAULT_INDIVIDUAL_CONCURRENCY)
722            .max(1);
723        let batch_concurrency = self
724            .max_batch_concurrency
725            .unwrap_or(DEFAULT_BATCH_CONCURRENCY)
726            .max(1);
727
728        // Classify all operations
729        let (batchable, individual, failed) = partition(self.operations).await;
730
731        // Execute individual requests for items that are too large, concurrently
732        let individual_results = futures_util::stream::iter(individual)
733            .map({
734                let session = session.clone();
735                move |op| {
736                    let session = session.clone();
737                    async move { execute_individual(op, &session).await }
738                }
739            })
740            .buffer_unordered(individual_concurrency);
741
742        // Chunk batchable operations and execute as batch requests, concurrently
743        let batch_results = futures_util::stream::iter(iter_batches(batchable))
744            .map(move |chunk| {
745                let session = session.clone();
746                async move { execute_batch(chunk, &session).await }
747            })
748            .buffer_unordered(batch_concurrency)
749            .flat_map(futures_util::stream::iter);
750
751        let results = futures_util::stream::iter(failed)
752            .chain(individual_results)
753            .chain(batch_results);
754
755        OperationResults(results.boxed())
756    }
757
758    /// Sets the maximum number of concurrent individual (non-batch) requests.
759    ///
760    /// Operations that exceed the per-part size limit are sent as individual requests.
761    /// This controls how many such requests can be in-flight simultaneously.
762    /// Defaults to 5 if not set.
763    pub fn max_individual_concurrency(mut self, concurrency: usize) -> Self {
764        self.max_individual_concurrency = Some(concurrency);
765        self
766    }
767
768    /// Sets the maximum number of concurrent batch requests.
769    ///
770    /// Batchable operations are grouped into chunks and sent as multipart batch requests.
771    /// This controls how many such batch requests can be in-flight simultaneously.
772    /// Defaults to 3 if not set.
773    pub fn max_batch_concurrency(mut self, concurrency: usize) -> Self {
774        self.max_batch_concurrency = Some(concurrency);
775        self
776    }
777
778    /// Enqueues an operation.
779    ///
780    /// This method takes a [`GetBuilder`]/[`PutBuilder`]/[`DeleteBuilder`], which you can
781    /// construct using [`Session::get`]/[`Session::put`]/[`Session::delete`].
782    ///
783    /// **Important**: All pushed builders must originate from the same [`Session`] that was used
784    /// to create this [`ManyBuilder`]. Mixing builders from different sessions is not supported
785    /// and will result in all operations being executed against this [`ManyBuilder`]'s session,
786    /// silently ignoring the original builder's session.
787    #[allow(private_bounds)]
788    pub fn push<B: Into<BatchOperation>>(mut self, builder: B) -> Self {
789        self.operations.push(builder.into());
790        self
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    /// Creates a dummy sized op for use in `iter_batches` tests.
799    fn op(size: u64) -> (BatchOperation, u64) {
800        (
801            BatchOperation::Delete {
802                key: "k".to_owned(),
803            },
804            size,
805        )
806    }
807
808    fn batch_sizes(batches: &[Vec<BatchOperation>]) -> Vec<usize> {
809        batches.iter().map(Vec::len).collect()
810    }
811
812    fn batches(ops: Vec<(BatchOperation, u64)>) -> Vec<Vec<BatchOperation>> {
813        iter_batches(ops).collect()
814    }
815
816    fn put_with_zstd(size: usize) -> BatchOperation {
817        BatchOperation::Insert {
818            key: Some("k".to_owned()),
819            metadata: Metadata {
820                compression: Some(Compression::Zstd),
821                ..Default::default()
822            },
823            body: PutBody::Buffer(vec![0; size].into()),
824        }
825    }
826
827    #[tokio::test]
828    async fn zstd_put_at_limit_is_batchable() {
829        let size = 1_044_496;
830        let post_compression = zstd_safe::compress_bound(size);
831        assert!(post_compression == MAX_BATCH_PART_SIZE as usize);
832
833        core::assert_matches!(
834            classify(put_with_zstd(size)).await,
835            Classified::Batchable(_, s) if s == post_compression as u64
836        );
837    }
838
839    #[tokio::test]
840    async fn zstd_put_above_limit_is_individual() {
841        let size = 1_044_497;
842        let post_compression = zstd_safe::compress_bound(size);
843        assert!(post_compression > MAX_BATCH_PART_SIZE as usize);
844
845        core::assert_matches!(
846            classify(put_with_zstd(size)).await,
847            Classified::Individual(_)
848        );
849    }
850
851    #[test]
852    fn iter_batches_empty() {
853        assert!(batches(vec![]).is_empty());
854    }
855
856    #[test]
857    fn iter_batches_single_batch_count_limit() {
858        // 1000 tiny ops → exactly one batch
859        let ops: Vec<_> = (0..1000).map(|_| op(1)).collect();
860        assert_eq!(batch_sizes(&batches(ops)), vec![1000]);
861    }
862
863    #[test]
864    fn iter_batches_splits_on_count_limit() {
865        // 1001 tiny ops → two batches: 1000 + 1
866        let ops: Vec<_> = (0..1001).map(|_| op(1)).collect();
867        assert_eq!(batch_sizes(&batches(ops)), vec![1000, 1]);
868    }
869
870    #[test]
871    fn iter_batches_exactly_at_size_limit() {
872        // 100 ops of 1 MB each = exactly 100 MB → one batch
873        let ops: Vec<_> = (0..100).map(|_| op(1024 * 1024)).collect();
874        assert_eq!(batch_sizes(&batches(ops)), vec![100]);
875    }
876
877    #[test]
878    fn iter_batches_splits_on_size_limit() {
879        // 101 ops of 1 MB each = 101 MB → two batches: 100 + 1
880        let ops: Vec<_> = (0..101).map(|_| op(1024 * 1024)).collect();
881        assert_eq!(batch_sizes(&batches(ops)), vec![100, 1]);
882    }
883
884    #[test]
885    fn iter_batches_size_limit_hits_before_count_limit() {
886        // 200 ops of ~600 KB each → size limit triggers before the 1000-op count limit
887        let op_size = 600 * 1024;
888        let ops: Vec<_> = (0..200).map(|_| op(op_size)).collect();
889        let result = batches(ops);
890        // Each batch holds floor(100 MB / 600 KB) ops
891        let per_batch = (MAX_BATCH_BODY_SIZE / op_size) as usize;
892        assert!(result.len() > 1, "expected multiple batches");
893        for batch in &result[..result.len() - 1] {
894            assert_eq!(batch.len(), per_batch);
895        }
896    }
897}