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::metadata::{Compression, Metadata};
11use percent_encoding::NON_ALPHANUMERIC;
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
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 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 let encoded =
184 percent_encoding::percent_encode(key.as_bytes(), NON_ALPHANUMERIC).to_string();
185 headers.insert(
186 HeaderName::from_static(HEADER_BATCH_OPERATION_KEY),
187 HeaderValue::try_from(encoded)
188 .expect("percent-encoded string is always a valid header value"),
189 );
190 }
191 headers
192}
193
194#[derive(Debug)]
196pub enum OperationResult {
197 Get(ObjectKey, Result<Option<GetResponse>, Error>),
201 Put(ObjectKey, Result<PutResponse, Error>),
203 Delete(ObjectKey, Result<DeleteResponse, Error>),
205 Head(ObjectKey, Result<HeadResponse, Error>),
209 Error(Error),
215}
216
217enum OperationContext {
219 Get {
220 key: ObjectKey,
221 decompress: bool,
222 accept_encoding: Vec<Compression>,
223 },
224 Insert {
225 key: Option<ObjectKey>,
226 },
227 Delete {
228 key: ObjectKey,
229 },
230 Head {
231 key: ObjectKey,
232 },
233}
234
235impl From<&BatchOperation> for OperationContext {
236 fn from(op: &BatchOperation) -> Self {
237 match op {
238 BatchOperation::Get {
239 key,
240 decompress,
241 accept_encoding,
242 } => OperationContext::Get {
243 key: key.clone(),
244 decompress: *decompress,
245 accept_encoding: accept_encoding.clone(),
246 },
247 BatchOperation::Insert { key, .. } => OperationContext::Insert { key: key.clone() },
248 BatchOperation::Delete { key } => OperationContext::Delete { key: key.clone() },
249 BatchOperation::Head { key } => OperationContext::Head { key: key.clone() },
250 }
251 }
252}
253
254impl OperationContext {
255 fn key(&self) -> Option<&str> {
256 match self {
257 OperationContext::Get { key, .. }
258 | OperationContext::Delete { key }
259 | OperationContext::Head { key } => Some(key),
260 OperationContext::Insert { key } => key.as_deref(),
261 }
262 }
263}
264
265#[derive(Debug)]
267enum Classified {
268 Batchable(BatchOperation, u64),
270 Individual(BatchOperation),
272 Failed(OperationResult),
274}
275
276fn error_result(ctx: OperationContext, error: Error) -> OperationResult {
278 let key = ctx.key().unwrap_or("<unknown>").to_owned();
279 match ctx {
280 OperationContext::Get { .. } => OperationResult::Get(key, Err(error)),
281 OperationContext::Insert { .. } => OperationResult::Put(key, Err(error)),
282 OperationContext::Delete { .. } => OperationResult::Delete(key, Err(error)),
283 OperationContext::Head { .. } => OperationResult::Head(key, Err(error)),
284 }
285}
286
287impl OperationResult {
288 async fn from_field(
289 field: Field<'_>,
290 context_map: &HashMap<usize, OperationContext>,
291 ) -> (Option<usize>, Self) {
292 match Self::try_from_field(field, context_map).await {
293 Ok((index, result)) => (Some(index), result),
294 Err(e) => (None, OperationResult::Error(e)),
295 }
296 }
297
298 async fn try_from_field(
299 field: Field<'_>,
300 context_map: &HashMap<usize, OperationContext>,
301 ) -> Result<(usize, Self), Error> {
302 let mut headers = field.headers().clone();
303
304 let index: usize = headers
305 .remove(HEADER_BATCH_OPERATION_INDEX)
306 .and_then(|v| v.to_str().ok().and_then(|s| s.parse().ok()))
307 .ok_or_else(|| {
308 Error::MalformedResponse(format!(
309 "missing or invalid {HEADER_BATCH_OPERATION_INDEX} header"
310 ))
311 })?;
312
313 let status: u16 = headers
314 .remove(HEADER_BATCH_OPERATION_STATUS)
315 .and_then(|v| {
316 v.to_str().ok().and_then(|s| {
317 s.split_once(' ')
320 .map(|(code, _)| code)
321 .unwrap_or(s)
322 .parse()
323 .ok()
324 })
325 })
326 .ok_or_else(|| {
327 Error::MalformedResponse(format!(
328 "missing or invalid {HEADER_BATCH_OPERATION_STATUS} header"
329 ))
330 })?;
331
332 let ctx = context_map.get(&index).ok_or_else(|| {
333 Error::MalformedResponse(format!(
334 "response references unknown operation index {index}"
335 ))
336 })?;
337
338 let key = headers
340 .remove(HEADER_BATCH_OPERATION_KEY)
341 .and_then(|v| {
342 v.to_str()
343 .ok()
344 .and_then(|encoded| {
345 percent_encoding::percent_decode_str(encoded)
346 .decode_utf8()
347 .ok()
348 })
349 .map(|s| s.into_owned())
350 })
351 .or_else(|| ctx.key().map(str::to_owned));
352
353 let body = field.bytes().await?;
354
355 let is_error = status >= 400
356 && !(matches!(
357 ctx,
358 OperationContext::Get { .. } | OperationContext::Head { .. }
359 ) && status == 404);
360
361 let key = match key {
366 Some(key) => key,
367 None if is_error => "<unknown>".to_owned(),
368 None => {
369 return Err(Error::MalformedResponse(format!(
370 "missing or invalid {HEADER_BATCH_OPERATION_KEY} header"
371 )));
372 }
373 };
374 if is_error {
375 let message = String::from_utf8_lossy(&body).into_owned();
376 let error = Error::OperationFailure { status, message };
377
378 return Ok((
379 index,
380 match ctx {
381 OperationContext::Get { .. } => OperationResult::Get(key, Err(error)),
382 OperationContext::Insert { .. } => OperationResult::Put(key, Err(error)),
383 OperationContext::Delete { .. } => OperationResult::Delete(key, Err(error)),
384 OperationContext::Head { .. } => OperationResult::Head(key, Err(error)),
385 },
386 ));
387 }
388
389 let result = match ctx {
390 OperationContext::Get {
391 decompress,
392 accept_encoding,
393 ..
394 } => {
395 if status == 404 {
396 OperationResult::Get(key, Ok(None))
397 } else {
398 let mut metadata = Metadata::from_headers(&headers, "")?;
399
400 let stream =
401 futures_util::stream::once(async move { Ok::<_, io::Error>(body) }).boxed();
402 let stream =
403 get::maybe_decompress(stream, &mut metadata, *decompress, accept_encoding);
404
405 OperationResult::Get(key, Ok(Some(GetResponse { metadata, stream })))
406 }
407 }
408 OperationContext::Insert { .. } => {
409 OperationResult::Put(key.clone(), Ok(PutResponse { key }))
410 }
411 OperationContext::Delete { .. } => OperationResult::Delete(key, Ok(())),
412 OperationContext::Head { .. } => {
413 if status == 404 {
414 OperationResult::Head(key, Ok(None))
415 } else {
416 let metadata = Metadata::from_headers(&headers, "")?;
417 OperationResult::Head(key, Ok(Some(metadata)))
418 }
419 }
420 };
421 Ok((index, result))
422 }
423}
424
425pub struct OperationResults(Pin<Box<dyn Stream<Item = OperationResult> + Send>>);
427
428impl fmt::Debug for OperationResults {
429 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
430 f.write_str("OperationResults([Stream])")
431 }
432}
433
434impl Stream for OperationResults {
435 type Item = OperationResult;
436
437 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
438 self.0.as_mut().poll_next(cx)
439 }
440}
441
442impl OperationResults {
443 pub async fn error_for_failures(
448 mut self,
449 ) -> crate::Result<(), impl Iterator<Item = crate::Error>> {
450 let mut errs = Vec::new();
451 while let Some(res) = self.next().await {
452 match res {
453 OperationResult::Get(_, get) => {
454 if let Err(e) = get {
455 errs.push(e);
456 }
457 }
458 OperationResult::Put(_, put) => {
459 if let Err(e) = put {
460 errs.push(e);
461 }
462 }
463 OperationResult::Delete(_, delete) => {
464 if let Err(e) = delete {
465 errs.push(e);
466 }
467 }
468 OperationResult::Head(_, head) => {
469 if let Err(e) = head {
470 errs.push(e);
471 }
472 }
473 OperationResult::Error(error) => errs.push(error),
474 }
475 }
476 if errs.is_empty() {
477 return Ok(());
478 }
479 Err(errs.into_iter())
480 }
481}
482
483async fn send_batch(
484 session: &Session,
485 operations: Vec<BatchOperation>,
486) -> crate::Result<Vec<OperationResult>> {
487 let mut context_map: HashMap<usize, OperationContext> = operations
488 .iter()
489 .enumerate()
490 .map(|(idx, op)| (idx, OperationContext::from(op)))
491 .collect();
492 let num_operations = operations.len();
493
494 let mut form = reqwest::multipart::Form::new();
495 for op in operations.into_iter() {
496 let part = op.into_part().await?;
497 form = form.part("part", part);
498 }
499
500 let request = session.batch_request()?.multipart(form);
501 let response = request.send().await?.error_for_status()?;
502
503 let boundary = response
504 .headers()
505 .get(CONTENT_TYPE)
506 .and_then(|v| v.to_str().ok())
507 .ok_or_else(|| Error::MalformedResponse("missing Content-Type header".to_owned()))
508 .map(multer::parse_boundary)??;
509
510 let byte_stream = response.bytes_stream().map(|r| r.map_err(io::Error::other));
511 let mut multipart = multer::Multipart::new(byte_stream, boundary);
512
513 let mut results = Vec::new();
514 let mut seen_indices = HashSet::new();
515 while let Some(field) = multipart.next_field().await? {
516 let (index, result) = OperationResult::from_field(field, &context_map).await;
517 if let Some(idx) = index {
518 seen_indices.insert(idx);
519 }
520 results.push(result);
521 }
522
523 for idx in 0..num_operations {
524 if !seen_indices.contains(&idx) {
525 let error = Error::MalformedResponse(format!(
526 "server did not return a response for operation at index {idx}"
527 ));
528 let result = match context_map.remove(&idx) {
529 Some(ctx) => error_result(ctx, error),
530 None => OperationResult::Error(error),
531 };
532 results.push(result);
533 }
534 }
535
536 Ok(results)
537}
538
539fn classify_fail(key: Option<ObjectKey>, error: Error) -> Classified {
540 Classified::Failed(OperationResult::Put(
541 key.unwrap_or_else(|| "<unknown>".to_owned()),
542 Err(error),
543 ))
544}
545
546async fn classify(op: BatchOperation) -> Classified {
551 match op {
552 BatchOperation::Insert {
553 key,
554 metadata,
555 body,
556 } => {
557 let size = match &body {
558 PutBody::Buffer(bytes) => Some(bytes.len() as u64),
559 PutBody::File(file) => match file.metadata().await {
560 Ok(meta) => Some(meta.len()),
561 Err(err) => return classify_fail(key, err.into()),
562 },
563 PutBody::Path(path) => match tokio::fs::metadata(path).await {
564 Ok(meta) => Some(meta.len()),
565 Err(err) => return classify_fail(key, err.into()),
566 },
567 PutBody::Stream(_) => None,
569 };
570
571 let size = match (metadata.compression, size) {
572 (Some(Compression::Zstd), Some(size)) => {
573 usize::try_from(size).ok().map(zstd_safe::compress_bound)
574 }
575 (None, Some(size)) => usize::try_from(size).ok(),
576 (_, None) => None,
577 };
578
579 let op = BatchOperation::Insert {
580 key,
581 metadata,
582 body,
583 };
584
585 match size {
586 Some(s) if s <= MAX_BATCH_PART_SIZE as usize => Classified::Batchable(op, s as u64),
587 _ => Classified::Individual(op),
588 }
589 }
590 other => Classified::Batchable(other, 0),
591 }
592}
593
594async fn partition(
598 operations: Vec<BatchOperation>,
599) -> (
600 Vec<(BatchOperation, u64)>,
601 Vec<BatchOperation>,
602 Vec<OperationResult>,
603) {
604 let classified = futures_util::future::join_all(operations.into_iter().map(classify)).await;
605 let mut batchable = Vec::new();
606 let mut individual = Vec::new();
607 let mut failed = Vec::new();
608 for item in classified {
609 match item {
610 Classified::Batchable(op, size) => batchable.push((op, size)),
611 Classified::Individual(op) => individual.push(op),
612 Classified::Failed(result) => failed.push(result),
613 }
614 }
615 (batchable, individual, failed)
616}
617
618async fn execute_individual(op: BatchOperation, session: &Session) -> OperationResult {
620 match op {
621 BatchOperation::Get {
622 key,
623 decompress,
624 accept_encoding,
625 } => {
626 let get = GetBuilder {
627 session: session.clone(),
628 key: key.clone(),
629 decompress,
630 accept_encoding,
631 };
632 OperationResult::Get(key, get.send().await)
633 }
634 BatchOperation::Insert {
635 key,
636 metadata,
637 body,
638 } => {
639 let error_key = key.clone().unwrap_or_else(|| "<unknown>".to_owned());
640 let put = PutBuilder {
641 session: session.clone(),
642 metadata,
643 key,
644 body,
645 };
646 match put.send().await {
647 Ok(response) => OperationResult::Put(response.key.clone(), Ok(response)),
648 Err(err) => OperationResult::Put(error_key, Err(err)),
649 }
650 }
651 BatchOperation::Delete { key } => {
652 let delete = DeleteBuilder {
653 session: session.clone(),
654 key: key.clone(),
655 };
656 OperationResult::Delete(key, delete.send().await)
657 }
658 BatchOperation::Head { key } => {
659 let head = HeadBuilder {
660 session: session.clone(),
661 key: key.clone(),
662 };
663 OperationResult::Head(key, head.send().await)
664 }
665 }
666}
667
668async fn execute_batch(operations: Vec<BatchOperation>, session: &Session) -> Vec<OperationResult> {
672 let contexts: Vec<_> = operations.iter().map(OperationContext::from).collect();
673 match send_batch(session, operations).await {
674 Ok(results) => results,
675 Err(e) => {
676 let shared = Arc::new(e);
677 contexts
678 .into_iter()
679 .map(|ctx| error_result(ctx, Error::Batch(shared.clone())))
680 .collect()
681 }
682 }
683}
684
685fn iter_batches(ops: Vec<(BatchOperation, u64)>) -> impl Iterator<Item = Vec<BatchOperation>> {
690 let mut remaining = ops.into_iter().peekable();
691
692 std::iter::from_fn(move || {
693 remaining.peek()?;
694 let mut batch_size = 0;
695 let mut batch = Vec::new();
696
697 while let Some((_, op_size)) = remaining.peek() {
698 if batch.len() >= MAX_BATCH_OPS
699 || (!batch.is_empty() && batch_size + op_size > MAX_BATCH_BODY_SIZE)
700 {
701 break;
702 }
703
704 let (op, op_size) = remaining.next().expect("peeked above");
705 batch_size += op_size;
706 batch.push(op);
707 }
708
709 Some(batch)
710 })
711}
712
713impl ManyBuilder {
714 pub async fn send(self) -> OperationResults {
718 let session = self.session;
719 let individual_concurrency = self
720 .max_individual_concurrency
721 .unwrap_or(DEFAULT_INDIVIDUAL_CONCURRENCY)
722 .max(1);
723 let batch_concurrency = self
724 .max_batch_concurrency
725 .unwrap_or(DEFAULT_BATCH_CONCURRENCY)
726 .max(1);
727
728 let (batchable, individual, failed) = partition(self.operations).await;
730
731 let individual_results = futures_util::stream::iter(individual)
733 .map({
734 let session = session.clone();
735 move |op| {
736 let session = session.clone();
737 async move { execute_individual(op, &session).await }
738 }
739 })
740 .buffer_unordered(individual_concurrency);
741
742 let batch_results = futures_util::stream::iter(iter_batches(batchable))
744 .map(move |chunk| {
745 let session = session.clone();
746 async move { execute_batch(chunk, &session).await }
747 })
748 .buffer_unordered(batch_concurrency)
749 .flat_map(futures_util::stream::iter);
750
751 let results = futures_util::stream::iter(failed)
752 .chain(individual_results)
753 .chain(batch_results);
754
755 OperationResults(results.boxed())
756 }
757
758 pub fn max_individual_concurrency(mut self, concurrency: usize) -> Self {
764 self.max_individual_concurrency = Some(concurrency);
765 self
766 }
767
768 pub fn max_batch_concurrency(mut self, concurrency: usize) -> Self {
774 self.max_batch_concurrency = Some(concurrency);
775 self
776 }
777
778 #[allow(private_bounds)]
788 pub fn push<B: Into<BatchOperation>>(mut self, builder: B) -> Self {
789 self.operations.push(builder.into());
790 self
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797
798 fn op(size: u64) -> (BatchOperation, u64) {
800 (
801 BatchOperation::Delete {
802 key: "k".to_owned(),
803 },
804 size,
805 )
806 }
807
808 fn batch_sizes(batches: &[Vec<BatchOperation>]) -> Vec<usize> {
809 batches.iter().map(Vec::len).collect()
810 }
811
812 fn batches(ops: Vec<(BatchOperation, u64)>) -> Vec<Vec<BatchOperation>> {
813 iter_batches(ops).collect()
814 }
815
816 fn put_with_zstd(size: usize) -> BatchOperation {
817 BatchOperation::Insert {
818 key: Some("k".to_owned()),
819 metadata: Metadata {
820 compression: Some(Compression::Zstd),
821 ..Default::default()
822 },
823 body: PutBody::Buffer(vec![0; size].into()),
824 }
825 }
826
827 #[tokio::test]
828 async fn zstd_put_at_limit_is_batchable() {
829 let size = 1_044_496;
830 let post_compression = zstd_safe::compress_bound(size);
831 assert!(post_compression == MAX_BATCH_PART_SIZE as usize);
832
833 core::assert_matches!(
834 classify(put_with_zstd(size)).await,
835 Classified::Batchable(_, s) if s == post_compression as u64
836 );
837 }
838
839 #[tokio::test]
840 async fn zstd_put_above_limit_is_individual() {
841 let size = 1_044_497;
842 let post_compression = zstd_safe::compress_bound(size);
843 assert!(post_compression > MAX_BATCH_PART_SIZE as usize);
844
845 core::assert_matches!(
846 classify(put_with_zstd(size)).await,
847 Classified::Individual(_)
848 );
849 }
850
851 #[test]
852 fn iter_batches_empty() {
853 assert!(batches(vec![]).is_empty());
854 }
855
856 #[test]
857 fn iter_batches_single_batch_count_limit() {
858 let ops: Vec<_> = (0..1000).map(|_| op(1)).collect();
860 assert_eq!(batch_sizes(&batches(ops)), vec![1000]);
861 }
862
863 #[test]
864 fn iter_batches_splits_on_count_limit() {
865 let ops: Vec<_> = (0..1001).map(|_| op(1)).collect();
867 assert_eq!(batch_sizes(&batches(ops)), vec![1000, 1]);
868 }
869
870 #[test]
871 fn iter_batches_exactly_at_size_limit() {
872 let ops: Vec<_> = (0..100).map(|_| op(1024 * 1024)).collect();
874 assert_eq!(batch_sizes(&batches(ops)), vec![100]);
875 }
876
877 #[test]
878 fn iter_batches_splits_on_size_limit() {
879 let ops: Vec<_> = (0..101).map(|_| op(1024 * 1024)).collect();
881 assert_eq!(batch_sizes(&batches(ops)), vec![100, 1]);
882 }
883
884 #[test]
885 fn iter_batches_size_limit_hits_before_count_limit() {
886 let op_size = 600 * 1024;
888 let ops: Vec<_> = (0..200).map(|_| op(op_size)).collect();
889 let result = batches(ops);
890 let per_batch = (MAX_BATCH_BODY_SIZE / op_size) as usize;
892 assert!(result.len() > 1, "expected multiple batches");
893 for batch in &result[..result.len() - 1] {
894 assert_eq!(batch.len(), per_batch);
895 }
896 }
897}