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