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
27const MAX_BATCH_OPS: usize = 1000;
29
30const MAX_BATCH_PART_SIZE: u32 = 1024 * 1024; const DEFAULT_INDIVIDUAL_CONCURRENCY: usize = 5;
37
38const DEFAULT_BATCH_CONCURRENCY: usize = 3;
42
43const MAX_BATCH_BODY_SIZE: u64 = 100 * 1024 * 1024; #[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 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#[derive(Debug)]
198pub enum OperationResult {
199 Get(ObjectKey, Result<Option<GetResponse>, Error>),
203 Put(ObjectKey, Result<PutResponse, Error>),
205 Delete(ObjectKey, Result<DeleteResponse, Error>),
207 Head(ObjectKey, Result<HeadResponse, Error>),
211 Error(Error),
217}
218
219enum 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#[derive(Debug)]
269enum Classified {
270 Batchable(BatchOperation, u64),
272 Individual(BatchOperation),
274 Failed(OperationResult),
276}
277
278fn 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 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 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 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
418pub 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 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
537async 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 PutBody::Stream(_) => None,
561 };
562
563 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
591async 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
615async 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
667async 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
684fn 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 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 let (batchable, individual, failed) = partition(self.operations).await;
729
730 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 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 pub fn max_individual_concurrency(mut self, concurrency: usize) -> Self {
763 self.max_individual_concurrency = Some(concurrency);
764 self
765 }
766
767 pub fn max_batch_concurrency(mut self, concurrency: usize) -> Self {
773 self.max_batch_concurrency = Some(concurrency);
774 self
775 }
776
777 #[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 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 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 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 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 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 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 let op_size = 600 * 1024;
903 let ops: Vec<_> = (0..200).map(|_| op(op_size)).collect();
904 let result = batches(ops);
905 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}