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 compress_bound(compression: Compression, size: usize) -> usize {
536 match compression {
537 Compression::Zstd => zstd_safe::compress_bound(size),
538 Compression::Gzip => deflate_bound(size) + GZIP_FRAMING_SIZE,
539 }
540}
541
542const GZIP_FRAMING_SIZE: usize = 18;
544
545fn deflate_bound(size: usize) -> usize {
547 size + (size >> 12) + (size >> 14) + (size >> 25) + 7
552}
553
554fn classify_fail(key: Option<ObjectKey>, error: Error) -> Classified {
555 Classified::Failed(OperationResult::Put(
556 key.unwrap_or_else(|| "<unknown>".to_owned()),
557 Err(error),
558 ))
559}
560
561async fn classify(op: BatchOperation) -> Classified {
566 match op {
567 BatchOperation::Insert {
568 key,
569 metadata,
570 compression,
571 body,
572 } => {
573 let size = match &body {
574 PutBody::Buffer(bytes) => Some(bytes.len() as u64),
575 PutBody::File(file) => match file.metadata().await {
576 Ok(meta) => Some(meta.len()),
577 Err(err) => return classify_fail(key, err.into()),
578 },
579 PutBody::Path(path) => match tokio::fs::metadata(path).await {
580 Ok(meta) => Some(meta.len()),
581 Err(err) => return classify_fail(key, err.into()),
582 },
583 PutBody::Stream(_) => None,
585 };
586
587 let size = match (compression, size) {
590 (Some(CompressionMode::Compress(compression)), Some(size)) => usize::try_from(size)
591 .ok()
592 .map(|size| compress_bound(compression, size)),
593 (Some(CompressionMode::Precompressed(_)) | None, Some(size)) => {
594 usize::try_from(size).ok()
595 }
596 (_, None) => None,
597 };
598
599 let op = BatchOperation::Insert {
600 key,
601 metadata,
602 compression,
603 body,
604 };
605
606 match size {
607 Some(s) if s <= MAX_BATCH_PART_SIZE as usize => Classified::Batchable(op, s as u64),
608 _ => Classified::Individual(op),
609 }
610 }
611 other => Classified::Batchable(other, 0),
612 }
613}
614
615async fn partition(
619 operations: Vec<BatchOperation>,
620) -> (
621 Vec<(BatchOperation, u64)>,
622 Vec<BatchOperation>,
623 Vec<OperationResult>,
624) {
625 let classified = futures_util::future::join_all(operations.into_iter().map(classify)).await;
626 let mut batchable = Vec::new();
627 let mut individual = Vec::new();
628 let mut failed = Vec::new();
629 for item in classified {
630 match item {
631 Classified::Batchable(op, size) => batchable.push((op, size)),
632 Classified::Individual(op) => individual.push(op),
633 Classified::Failed(result) => failed.push(result),
634 }
635 }
636 (batchable, individual, failed)
637}
638
639async fn execute_individual(op: BatchOperation, session: &Session) -> OperationResult {
641 match op {
642 BatchOperation::Get {
643 key,
644 decompress,
645 accept_encoding,
646 } => {
647 let get = GetBuilder {
648 session: session.clone(),
649 key: key.clone(),
650 decompress,
651 accept_encoding,
652 };
653 OperationResult::Get(key, get.send().await)
654 }
655 BatchOperation::Insert {
656 key,
657 metadata,
658 compression,
659 body,
660 } => {
661 let error_key = key.clone().unwrap_or_else(|| "<unknown>".to_owned());
662 let put = PutBuilder {
663 session: session.clone(),
664 metadata,
665 compression,
666 key,
667 body,
668 };
669 match put.send().await {
670 Ok(response) => OperationResult::Put(response.key.clone(), Ok(response)),
671 Err(err) => OperationResult::Put(error_key, Err(err)),
672 }
673 }
674 BatchOperation::Delete { key } => {
675 let delete = DeleteBuilder {
676 session: session.clone(),
677 key: key.clone(),
678 };
679 OperationResult::Delete(key, delete.send().await)
680 }
681 BatchOperation::Head { key } => {
682 let head = HeadBuilder {
683 session: session.clone(),
684 key: key.clone(),
685 };
686 OperationResult::Head(key, head.send().await)
687 }
688 }
689}
690
691async fn execute_batch(operations: Vec<BatchOperation>, session: &Session) -> Vec<OperationResult> {
695 let contexts: Vec<_> = operations.iter().map(OperationContext::from).collect();
696 match send_batch(session, operations).await {
697 Ok(results) => results,
698 Err(e) => {
699 let shared = Arc::new(e);
700 contexts
701 .into_iter()
702 .map(|ctx| error_result(ctx, Error::Batch(shared.clone())))
703 .collect()
704 }
705 }
706}
707
708fn iter_batches(ops: Vec<(BatchOperation, u64)>) -> impl Iterator<Item = Vec<BatchOperation>> {
713 let mut remaining = ops.into_iter().peekable();
714
715 std::iter::from_fn(move || {
716 remaining.peek()?;
717 let mut batch_size = 0;
718 let mut batch = Vec::new();
719
720 while let Some((_, op_size)) = remaining.peek() {
721 if batch.len() >= MAX_BATCH_OPS
722 || (!batch.is_empty() && batch_size + op_size > MAX_BATCH_BODY_SIZE)
723 {
724 break;
725 }
726
727 let (op, op_size) = remaining.next().expect("peeked above");
728 batch_size += op_size;
729 batch.push(op);
730 }
731
732 Some(batch)
733 })
734}
735
736impl ManyBuilder {
737 pub async fn send(self) -> OperationResults {
741 let session = self.session;
742 let individual_concurrency = self
743 .max_individual_concurrency
744 .unwrap_or(DEFAULT_INDIVIDUAL_CONCURRENCY)
745 .max(1);
746 let batch_concurrency = self
747 .max_batch_concurrency
748 .unwrap_or(DEFAULT_BATCH_CONCURRENCY)
749 .max(1);
750
751 let (batchable, individual, failed) = partition(self.operations).await;
753
754 let individual_results = futures_util::stream::iter(individual)
756 .map({
757 let session = session.clone();
758 move |op| {
759 let session = session.clone();
760 async move { execute_individual(op, &session).await }
761 }
762 })
763 .buffer_unordered(individual_concurrency);
764
765 let batch_results = futures_util::stream::iter(iter_batches(batchable))
767 .map(move |chunk| {
768 let session = session.clone();
769 async move { execute_batch(chunk, &session).await }
770 })
771 .buffer_unordered(batch_concurrency)
772 .flat_map(futures_util::stream::iter);
773
774 let results = futures_util::stream::iter(failed)
775 .chain(individual_results)
776 .chain(batch_results);
777
778 OperationResults(results.boxed())
779 }
780
781 pub fn max_individual_concurrency(mut self, concurrency: usize) -> Self {
787 self.max_individual_concurrency = Some(concurrency);
788 self
789 }
790
791 pub fn max_batch_concurrency(mut self, concurrency: usize) -> Self {
797 self.max_batch_concurrency = Some(concurrency);
798 self
799 }
800
801 #[allow(private_bounds)]
811 pub fn push<B: Into<BatchOperation>>(mut self, builder: B) -> Self {
812 self.operations.push(builder.into());
813 self
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 fn op(size: u64) -> (BatchOperation, u64) {
823 (
824 BatchOperation::Delete {
825 key: "k".to_owned(),
826 },
827 size,
828 )
829 }
830
831 fn batch_sizes(batches: &[Vec<BatchOperation>]) -> Vec<usize> {
832 batches.iter().map(Vec::len).collect()
833 }
834
835 fn batches(ops: Vec<(BatchOperation, u64)>) -> Vec<Vec<BatchOperation>> {
836 iter_batches(ops).collect()
837 }
838
839 fn put_with_compression(size: usize, compression: CompressionMode) -> BatchOperation {
840 BatchOperation::Insert {
841 key: Some("k".to_owned()),
842 metadata: Metadata::default(),
843 compression: Some(compression),
844 body: PutBody::Buffer(vec![0; size].into()),
845 }
846 }
847
848 fn put_with_zstd(size: usize) -> BatchOperation {
849 put_with_compression(size, CompressionMode::Compress(Compression::Zstd))
850 }
851
852 #[tokio::test]
853 async fn zstd_put_at_limit_is_batchable() {
854 let size = 1_044_496;
855 let post_compression = zstd_safe::compress_bound(size);
856 assert!(post_compression == MAX_BATCH_PART_SIZE as usize);
857
858 core::assert_matches!(
859 classify(put_with_zstd(size)).await,
860 Classified::Batchable(_, s) if s == post_compression as u64
861 );
862 }
863
864 #[tokio::test]
865 async fn zstd_put_above_limit_is_individual() {
866 let size = 1_044_497;
867 let post_compression = zstd_safe::compress_bound(size);
868 assert!(post_compression > MAX_BATCH_PART_SIZE as usize);
869
870 core::assert_matches!(
871 classify(put_with_zstd(size)).await,
872 Classified::Individual(_)
873 );
874 }
875
876 #[tokio::test]
877 async fn gzip_put_bound() {
878 let size = 1_000_000;
879 let op = put_with_compression(size, CompressionMode::Compress(Compression::Gzip));
880 std::assert_matches!(
881 classify(op).await,
882 Classified::Batchable(_, s) if s == compress_bound(Compression::Gzip, size) as u64
883 );
884 }
885
886 #[tokio::test]
887 async fn precompressed_put_uses_exact_size() {
888 let size = MAX_BATCH_PART_SIZE as usize;
891 assert!(zstd_safe::compress_bound(size) > MAX_BATCH_PART_SIZE as usize);
892
893 let op = put_with_compression(size, CompressionMode::Precompressed(Compression::Zstd));
894 core::assert_matches!(
895 classify(op).await,
896 Classified::Batchable(_, s) if s == size as u64
897 );
898 }
899
900 #[test]
901 fn iter_batches_empty() {
902 assert!(batches(vec![]).is_empty());
903 }
904
905 #[test]
906 fn iter_batches_single_batch_count_limit() {
907 let ops: Vec<_> = (0..1000).map(|_| op(1)).collect();
909 assert_eq!(batch_sizes(&batches(ops)), vec![1000]);
910 }
911
912 #[test]
913 fn iter_batches_splits_on_count_limit() {
914 let ops: Vec<_> = (0..1001).map(|_| op(1)).collect();
916 assert_eq!(batch_sizes(&batches(ops)), vec![1000, 1]);
917 }
918
919 #[test]
920 fn iter_batches_exactly_at_size_limit() {
921 let ops: Vec<_> = (0..100).map(|_| op(1024 * 1024)).collect();
923 assert_eq!(batch_sizes(&batches(ops)), vec![100]);
924 }
925
926 #[test]
927 fn iter_batches_splits_on_size_limit() {
928 let ops: Vec<_> = (0..101).map(|_| op(1024 * 1024)).collect();
930 assert_eq!(batch_sizes(&batches(ops)), vec![100, 1]);
931 }
932
933 #[test]
934 fn iter_batches_size_limit_hits_before_count_limit() {
935 let op_size = 600 * 1024;
937 let ops: Vec<_> = (0..200).map(|_| op(op_size)).collect();
938 let result = batches(ops);
939 let per_batch = (MAX_BATCH_BODY_SIZE / op_size) as usize;
941 assert!(result.len() > 1, "expected multiple batches");
942 for batch in &result[..result.len() - 1] {
943 assert_eq!(batch.len(), per_batch);
944 }
945 }
946}