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