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