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
28const MAX_BATCH_OPS: usize = 1000;
30
31const MAX_BATCH_PART_SIZE: u32 = 1024 * 1024; const DEFAULT_INDIVIDUAL_CONCURRENCY: usize = 5;
38
39const DEFAULT_BATCH_CONCURRENCY: usize = 3;
43
44const MAX_BATCH_BODY_SIZE: u64 = 100 * 1024 * 1024; #[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 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#[derive(Debug)]
199pub enum OperationResult {
200 Get(ObjectKey, Result<Option<GetResponse>, Error>),
204 Put(ObjectKey, Result<PutResponse, Error>),
206 Delete(ObjectKey, Result<DeleteResponse, Error>),
208 Head(ObjectKey, Result<HeadResponse, Error>),
212 Error(Error),
218}
219
220enum 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#[derive(Debug)]
270enum Classified {
271 Batchable(BatchOperation, u64),
273 Individual(BatchOperation),
275 Failed(OperationResult),
277}
278
279fn 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 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 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 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
419pub 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 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 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
534fn classify_fail(key: Option<ObjectKey>, error: Error) -> Classified {
535 Classified::Failed(OperationResult::Put(
536 key.unwrap_or_else(|| "<unknown>".to_owned()),
537 Err(error),
538 ))
539}
540
541async fn classify(op: BatchOperation) -> Classified {
546 match op {
547 BatchOperation::Insert {
548 key,
549 metadata,
550 compression,
551 body,
552 } => {
553 let size = match &body {
554 PutBody::Buffer(bytes) => Some(bytes.len() as u64),
555 PutBody::File(file) => match file.metadata().await {
556 Ok(meta) => Some(meta.len()),
557 Err(err) => return classify_fail(key, err.into()),
558 },
559 PutBody::Path(path) => match tokio::fs::metadata(path).await {
560 Ok(meta) => Some(meta.len()),
561 Err(err) => return classify_fail(key, err.into()),
562 },
563 PutBody::Stream(_) => None,
565 };
566
567 let size = match (compression, size) {
570 (Some(CompressionMode::Compress(Compression::Zstd)), Some(size)) => {
571 usize::try_from(size).ok().map(zstd_safe::compress_bound)
572 }
573 (Some(CompressionMode::Precompressed(_)) | None, Some(size)) => {
574 usize::try_from(size).ok()
575 }
576 (_, None) => None,
577 };
578
579 let op = BatchOperation::Insert {
580 key,
581 metadata,
582 compression,
583 body,
584 };
585
586 match size {
587 Some(s) if s <= MAX_BATCH_PART_SIZE as usize => Classified::Batchable(op, s as u64),
588 _ => Classified::Individual(op),
589 }
590 }
591 other => Classified::Batchable(other, 0),
592 }
593}
594
595async fn partition(
599 operations: Vec<BatchOperation>,
600) -> (
601 Vec<(BatchOperation, u64)>,
602 Vec<BatchOperation>,
603 Vec<OperationResult>,
604) {
605 let classified = futures_util::future::join_all(operations.into_iter().map(classify)).await;
606 let mut batchable = Vec::new();
607 let mut individual = Vec::new();
608 let mut failed = Vec::new();
609 for item in classified {
610 match item {
611 Classified::Batchable(op, size) => batchable.push((op, size)),
612 Classified::Individual(op) => individual.push(op),
613 Classified::Failed(result) => failed.push(result),
614 }
615 }
616 (batchable, individual, failed)
617}
618
619async fn execute_individual(op: BatchOperation, session: &Session) -> OperationResult {
621 match op {
622 BatchOperation::Get {
623 key,
624 decompress,
625 accept_encoding,
626 } => {
627 let get = GetBuilder {
628 session: session.clone(),
629 key: key.clone(),
630 decompress,
631 accept_encoding,
632 };
633 OperationResult::Get(key, get.send().await)
634 }
635 BatchOperation::Insert {
636 key,
637 metadata,
638 compression,
639 body,
640 } => {
641 let error_key = key.clone().unwrap_or_else(|| "<unknown>".to_owned());
642 let put = PutBuilder {
643 session: session.clone(),
644 metadata,
645 compression,
646 key,
647 body,
648 };
649 match put.send().await {
650 Ok(response) => OperationResult::Put(response.key.clone(), Ok(response)),
651 Err(err) => OperationResult::Put(error_key, Err(err)),
652 }
653 }
654 BatchOperation::Delete { key } => {
655 let delete = DeleteBuilder {
656 session: session.clone(),
657 key: key.clone(),
658 };
659 OperationResult::Delete(key, delete.send().await)
660 }
661 BatchOperation::Head { key } => {
662 let head = HeadBuilder {
663 session: session.clone(),
664 key: key.clone(),
665 };
666 OperationResult::Head(key, head.send().await)
667 }
668 }
669}
670
671async fn execute_batch(operations: Vec<BatchOperation>, session: &Session) -> Vec<OperationResult> {
675 let contexts: Vec<_> = operations.iter().map(OperationContext::from).collect();
676 match send_batch(session, operations).await {
677 Ok(results) => results,
678 Err(e) => {
679 let shared = Arc::new(e);
680 contexts
681 .into_iter()
682 .map(|ctx| error_result(ctx, Error::Batch(shared.clone())))
683 .collect()
684 }
685 }
686}
687
688fn iter_batches(ops: Vec<(BatchOperation, u64)>) -> impl Iterator<Item = Vec<BatchOperation>> {
693 let mut remaining = ops.into_iter().peekable();
694
695 std::iter::from_fn(move || {
696 remaining.peek()?;
697 let mut batch_size = 0;
698 let mut batch = Vec::new();
699
700 while let Some((_, op_size)) = remaining.peek() {
701 if batch.len() >= MAX_BATCH_OPS
702 || (!batch.is_empty() && batch_size + op_size > MAX_BATCH_BODY_SIZE)
703 {
704 break;
705 }
706
707 let (op, op_size) = remaining.next().expect("peeked above");
708 batch_size += op_size;
709 batch.push(op);
710 }
711
712 Some(batch)
713 })
714}
715
716impl ManyBuilder {
717 pub async fn send(self) -> OperationResults {
721 let session = self.session;
722 let individual_concurrency = self
723 .max_individual_concurrency
724 .unwrap_or(DEFAULT_INDIVIDUAL_CONCURRENCY)
725 .max(1);
726 let batch_concurrency = self
727 .max_batch_concurrency
728 .unwrap_or(DEFAULT_BATCH_CONCURRENCY)
729 .max(1);
730
731 let (batchable, individual, failed) = partition(self.operations).await;
733
734 let individual_results = futures_util::stream::iter(individual)
736 .map({
737 let session = session.clone();
738 move |op| {
739 let session = session.clone();
740 async move { execute_individual(op, &session).await }
741 }
742 })
743 .buffer_unordered(individual_concurrency);
744
745 let batch_results = futures_util::stream::iter(iter_batches(batchable))
747 .map(move |chunk| {
748 let session = session.clone();
749 async move { execute_batch(chunk, &session).await }
750 })
751 .buffer_unordered(batch_concurrency)
752 .flat_map(futures_util::stream::iter);
753
754 let results = futures_util::stream::iter(failed)
755 .chain(individual_results)
756 .chain(batch_results);
757
758 OperationResults(results.boxed())
759 }
760
761 pub fn max_individual_concurrency(mut self, concurrency: usize) -> Self {
767 self.max_individual_concurrency = Some(concurrency);
768 self
769 }
770
771 pub fn max_batch_concurrency(mut self, concurrency: usize) -> Self {
777 self.max_batch_concurrency = Some(concurrency);
778 self
779 }
780
781 #[allow(private_bounds)]
791 pub fn push<B: Into<BatchOperation>>(mut self, builder: B) -> Self {
792 self.operations.push(builder.into());
793 self
794 }
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800
801 fn op(size: u64) -> (BatchOperation, u64) {
803 (
804 BatchOperation::Delete {
805 key: "k".to_owned(),
806 },
807 size,
808 )
809 }
810
811 fn batch_sizes(batches: &[Vec<BatchOperation>]) -> Vec<usize> {
812 batches.iter().map(Vec::len).collect()
813 }
814
815 fn batches(ops: Vec<(BatchOperation, u64)>) -> Vec<Vec<BatchOperation>> {
816 iter_batches(ops).collect()
817 }
818
819 fn put_with_compression(size: usize, compression: CompressionMode) -> BatchOperation {
820 BatchOperation::Insert {
821 key: Some("k".to_owned()),
822 metadata: Metadata::default(),
823 compression: Some(compression),
824 body: PutBody::Buffer(vec![0; size].into()),
825 }
826 }
827
828 fn put_with_zstd(size: usize) -> BatchOperation {
829 put_with_compression(size, CompressionMode::Compress(Compression::Zstd))
830 }
831
832 #[tokio::test]
833 async fn zstd_put_at_limit_is_batchable() {
834 let size = 1_044_496;
835 let post_compression = zstd_safe::compress_bound(size);
836 assert!(post_compression == MAX_BATCH_PART_SIZE as usize);
837
838 core::assert_matches!(
839 classify(put_with_zstd(size)).await,
840 Classified::Batchable(_, s) if s == post_compression as u64
841 );
842 }
843
844 #[tokio::test]
845 async fn zstd_put_above_limit_is_individual() {
846 let size = 1_044_497;
847 let post_compression = zstd_safe::compress_bound(size);
848 assert!(post_compression > MAX_BATCH_PART_SIZE as usize);
849
850 core::assert_matches!(
851 classify(put_with_zstd(size)).await,
852 Classified::Individual(_)
853 );
854 }
855
856 #[tokio::test]
857 async fn precompressed_put_uses_exact_size() {
858 let size = MAX_BATCH_PART_SIZE as usize;
861 assert!(zstd_safe::compress_bound(size) > MAX_BATCH_PART_SIZE as usize);
862
863 let op = put_with_compression(size, CompressionMode::Precompressed(Compression::Zstd));
864 core::assert_matches!(
865 classify(op).await,
866 Classified::Batchable(_, s) if s == size as u64
867 );
868 }
869
870 #[test]
871 fn iter_batches_empty() {
872 assert!(batches(vec![]).is_empty());
873 }
874
875 #[test]
876 fn iter_batches_single_batch_count_limit() {
877 let ops: Vec<_> = (0..1000).map(|_| op(1)).collect();
879 assert_eq!(batch_sizes(&batches(ops)), vec![1000]);
880 }
881
882 #[test]
883 fn iter_batches_splits_on_count_limit() {
884 let ops: Vec<_> = (0..1001).map(|_| op(1)).collect();
886 assert_eq!(batch_sizes(&batches(ops)), vec![1000, 1]);
887 }
888
889 #[test]
890 fn iter_batches_exactly_at_size_limit() {
891 let ops: Vec<_> = (0..100).map(|_| op(1024 * 1024)).collect();
893 assert_eq!(batch_sizes(&batches(ops)), vec![100]);
894 }
895
896 #[test]
897 fn iter_batches_splits_on_size_limit() {
898 let ops: Vec<_> = (0..101).map(|_| op(1024 * 1024)).collect();
900 assert_eq!(batch_sizes(&batches(ops)), vec![100, 1]);
901 }
902
903 #[test]
904 fn iter_batches_size_limit_hits_before_count_limit() {
905 let op_size = 600 * 1024;
907 let ops: Vec<_> = (0..200).map(|_| op(op_size)).collect();
908 let result = batches(ops);
909 let per_batch = (MAX_BATCH_BODY_SIZE / op_size) as usize;
911 assert!(result.len() > 1, "expected multiple batches");
912 for batch in &result[..result.len() - 1] {
913 assert_eq!(batch.len(), per_batch);
914 }
915 }
916}