1use std::collections::HashSet;
16
17use api::v1::{ArrowIpc, SemanticType};
18use bytes::Bytes;
19use common_grpc::flight::{FlightEncoder, FlightMessage};
20use datatypes::arrow::record_batch::RecordBatch;
21use snafu::{OptionExt, ensure};
22use store_api::codec::PrimaryKeyEncoding;
23use store_api::metadata::RegionMetadataRef;
24use store_api::region_request::{AffectedRows, RegionBulkInsertsRequest, RegionRequest};
25use store_api::storage::RegionId;
26
27use crate::batch_modifier::{TagColumnInfo, modify_batch_sparse};
28use crate::engine::MetricEngineInner;
29use crate::error;
30use crate::error::Result;
31use crate::metrics::MITO_OPERATION_ELAPSED;
32
33impl MetricEngineInner {
34 pub async fn bulk_insert_region(
47 &self,
48 region_id: RegionId,
49 request: RegionBulkInsertsRequest,
50 ) -> Result<AffectedRows> {
51 if request.payload.num_rows() == 0 {
52 return Ok(0);
53 }
54 if self.is_physical_region(region_id) {
55 let _timer = MITO_OPERATION_ELAPSED
56 .with_label_values(&["bulk_insert_physical"])
57 .start_timer();
58 return self.bulk_insert_physical_region(region_id, request).await;
59 }
60
61 let _timer = MITO_OPERATION_ELAPSED
62 .with_label_values(&["bulk_insert_logical"])
63 .start_timer();
64 self.bulk_insert_logical_region(region_id, request).await
65 }
66
67 async fn bulk_insert_physical_region(
72 &self,
73 region_id: RegionId,
74 mut request: RegionBulkInsertsRequest,
75 ) -> Result<AffectedRows> {
76 request.aligned_schema_version = None;
79 self.data_region
80 .write_data(region_id, RegionRequest::BulkInserts(request))
81 .await
82 }
83
84 async fn bulk_insert_logical_region(
86 &self,
87 region_id: RegionId,
88 request: RegionBulkInsertsRequest,
89 ) -> Result<AffectedRows> {
90 let (physical_region_id, data_region_id, primary_key_encoding) =
91 self.find_data_region_meta(region_id)?;
92
93 if primary_key_encoding != PrimaryKeyEncoding::Sparse {
94 return error::UnsupportedRegionRequestSnafu {
95 request: RegionRequest::BulkInserts(request),
96 }
97 .fail();
98 }
99
100 let batch = request.payload;
101 if batch.num_rows() == 0 {
102 return Ok(0);
103 }
104
105 let logical_metadata = self
106 .logical_region_metadata(physical_region_id, region_id)
107 .await?;
108 let (tag_columns, non_tag_indices) = self.resolve_tag_columns_from_metadata(
109 region_id,
110 data_region_id,
111 &batch,
112 &logical_metadata,
113 )?;
114 let modified_batch = modify_batch_sparse(
115 batch.clone(),
116 region_id.table_id(),
117 &tag_columns,
118 &non_tag_indices,
119 )?;
120 let (schema, data_header, payload) = record_batch_to_ipc(&modified_batch)?;
121
122 let partition_expr_version = request.partition_expr_version;
123
124 let request = RegionBulkInsertsRequest {
125 skip_wal: request.skip_wal,
126 region_id: data_region_id,
127 payload: modified_batch,
128 raw_data: ArrowIpc {
129 schema,
130 data_header,
131 payload,
132 },
133 partition_expr_version,
134 aligned_schema_version: None,
135 };
136 self.data_region
137 .write_data(data_region_id, RegionRequest::BulkInserts(request))
138 .await
139 }
140
141 fn resolve_tag_columns_from_metadata(
142 &self,
143 logical_region_id: RegionId,
144 data_region_id: RegionId,
145 batch: &RecordBatch,
146 logical_metadata: &RegionMetadataRef,
147 ) -> Result<(Vec<TagColumnInfo>, Vec<usize>)> {
148 let tag_names: HashSet<&str> = logical_metadata
149 .column_metadatas
150 .iter()
151 .filter_map(|column| {
152 if column.semantic_type == SemanticType::Tag {
153 Some(column.column_schema.name.as_str())
154 } else {
155 None
156 }
157 })
158 .collect();
159
160 let mut tag_columns = Vec::new();
161 let mut non_tag_indices = Vec::new();
162 {
163 let state = self.state.read().unwrap();
164 let physical_columns = state
165 .physical_region_states()
166 .get(&data_region_id)
167 .context(error::PhysicalRegionNotFoundSnafu {
168 region_id: data_region_id,
169 })?
170 .physical_columns();
171
172 for (index, field) in batch.schema().fields().iter().enumerate() {
173 let name = field.name();
174 let column_id = physical_columns
175 .get(name)
176 .map(|info| info.column_id)
177 .with_context(|| error::ColumnNotFoundSnafu {
178 name: name.clone(),
179 region_id: logical_region_id,
180 })?;
181 if tag_names.contains(name.as_str()) {
182 tag_columns.push(TagColumnInfo {
183 name: name.clone(),
184 index,
185 column_id,
186 });
187 } else {
188 non_tag_indices.push(index);
189 }
190 }
191 }
192
193 tag_columns.sort_by(|a, b| a.name.cmp(&b.name));
194 Ok((tag_columns, non_tag_indices))
195 }
196}
197
198fn record_batch_to_ipc(record_batch: &RecordBatch) -> Result<(Bytes, Bytes, Bytes)> {
199 let mut encoder = FlightEncoder::default();
200 let schema = encoder.encode_schema(record_batch.schema().as_ref());
201 let mut iter = encoder
202 .encode(FlightMessage::RecordBatch(record_batch.clone()))
203 .into_iter();
204
205 let Some(flight_data) = iter.next() else {
206 return error::UnexpectedRequestSnafu {
207 reason: "Failed to encode empty flight data",
208 }
209 .fail();
210 };
211 ensure!(
212 iter.next().is_none(),
213 error::UnexpectedRequestSnafu {
214 reason: "Bulk insert RecordBatch with dictionary arrays is unsupported".to_string(),
215 }
216 );
217
218 Ok((
219 schema.data_header,
220 flight_data.data_header,
221 flight_data.data_body,
222 ))
223}
224
225#[cfg(test)]
226mod tests {
227 use std::assert_matches;
228 use std::sync::Arc;
229
230 use api::v1::ArrowIpc;
231 use common_error::ext::ErrorExt;
232 use common_query::prelude::{greptime_timestamp, greptime_value};
233 use common_recordbatch::RecordBatches;
234 use datafusion::parquet::basic::Encoding;
235 use datatypes::arrow::array::{Float64Array, StringArray, TimestampMillisecondArray};
236 use datatypes::arrow::datatypes::{DataType, Field, Schema as ArrowSchema, TimeUnit};
237 use datatypes::arrow::record_batch::RecordBatch;
238 use mito2::config::MitoConfig;
239 use mito2::sst::parquet::metadata::MetadataLoader;
240 use mito2::sst::parquet::reader::MetadataCacheMetrics;
241 use store_api::metric_engine_consts::{
242 METRIC_ENGINE_NAME, PHYSICAL_TABLE_METADATA_KEY, PRIMARY_KEY_ENCODING,
243 };
244 use store_api::mito_engine_options::EXPERIMENTAL_SST_FLOAT_FIELD_ENCODING;
245 use store_api::path_utils::table_dir;
246 use store_api::region_engine::RegionEngine;
247 use store_api::region_request::{
248 PathType, RegionBulkInsertsRequest, RegionCloseRequest, RegionFlushRequest,
249 RegionOpenRequest, RegionPutRequest, RegionRequest,
250 };
251 use store_api::storage::{RegionId, ScanRequest};
252
253 use super::record_batch_to_ipc;
254 use crate::batch_modifier::{TagColumnInfo, modify_batch_sparse};
255 use crate::engine::MetricEngine;
256 use crate::error::Error;
257 use crate::test_util::{self, TestEnv};
258
259 fn build_logical_batch(start: usize, rows: usize) -> RecordBatch {
260 let schema = Arc::new(ArrowSchema::new(vec![
261 Field::new(
262 greptime_timestamp(),
263 DataType::Timestamp(TimeUnit::Millisecond, None),
264 false,
265 ),
266 Field::new(greptime_value(), DataType::Float64, true),
267 Field::new("job", DataType::Utf8, true),
268 ]));
269
270 let mut ts = Vec::with_capacity(rows);
271 let mut values = Vec::with_capacity(rows);
272 let mut tags = Vec::with_capacity(rows);
273 for i in start..start + rows {
274 ts.push(i as i64);
275 values.push(i as f64);
276 tags.push("tag_0".to_string());
277 }
278
279 RecordBatch::try_new(
280 schema,
281 vec![
282 Arc::new(TimestampMillisecondArray::from(ts)),
283 Arc::new(Float64Array::from(values)),
284 Arc::new(StringArray::from(tags)),
285 ],
286 )
287 .unwrap()
288 }
289
290 fn build_bulk_request(
291 logical_region_id: RegionId,
292 batch: RecordBatch,
293 skip_wal: bool,
294 ) -> RegionRequest {
295 let (schema, data_header, payload) = record_batch_to_ipc(&batch).unwrap();
296 RegionRequest::BulkInserts(RegionBulkInsertsRequest {
297 skip_wal,
298 region_id: logical_region_id,
299 payload: batch,
300 raw_data: ArrowIpc {
301 schema,
302 data_header,
303 payload,
304 },
305 partition_expr_version: None,
306 aligned_schema_version: None,
307 })
308 }
309
310 async fn init_dense_metric_region(env: &TestEnv) -> RegionId {
311 let physical_region_id = env.default_physical_region_id();
312 env.create_physical_region(
313 physical_region_id,
314 &TestEnv::default_table_dir(),
315 vec![(PRIMARY_KEY_ENCODING.to_string(), "dense".to_string())],
316 )
317 .await;
318
319 let logical_region_id = env.default_logical_region_id();
320 let request = test_util::create_logical_region_request(
321 &["job"],
322 physical_region_id,
323 &table_dir("test", logical_region_id.table_id()),
324 );
325 env.metric()
326 .handle_request(logical_region_id, RegionRequest::Create(request))
327 .await
328 .unwrap();
329 logical_region_id
330 }
331
332 #[tokio::test]
333 async fn test_bulk_insert_empty_batch_returns_zero() {
334 let env = TestEnv::new().await;
335 env.init_metric_region().await;
336 let logical_region_id = env.default_logical_region_id();
337
338 let batch = build_logical_batch(0, 0);
339 let request = RegionRequest::BulkInserts(RegionBulkInsertsRequest {
340 skip_wal: false,
341 region_id: logical_region_id,
342 payload: batch,
343 raw_data: ArrowIpc::default(),
344 partition_expr_version: None,
345 aligned_schema_version: None,
346 });
347 let response = env
348 .metric()
349 .handle_request(logical_region_id, request)
350 .await
351 .unwrap();
352 assert_eq!(response.affected_rows, 0);
353 }
354
355 #[tokio::test]
356 async fn test_bulk_insert_physical_region_passthrough() {
357 check_bulk_insert_physical_region_passthrough(false, false, false).await;
358 check_bulk_insert_physical_region_passthrough(true, false, false).await;
359 }
360
361 #[tokio::test]
362 async fn test_bulk_insert_physical_region_close_recovery() {
363 check_bulk_insert_physical_region_passthrough(false, true, false).await;
364 check_bulk_insert_physical_region_passthrough(true, true, false).await;
365 }
366
367 #[tokio::test]
368 async fn test_physical_bulk_partition_columns_preserve_stored_rows() {
369 for skip_wal in [false, true] {
370 let stripped =
371 check_bulk_insert_physical_region_passthrough(skip_wal, true, false).await;
372 let retained =
373 check_bulk_insert_physical_region_passthrough(skip_wal, true, true).await;
374 assert_eq!(retained, stripped, "skip_wal={skip_wal}");
375 }
376 }
377
378 async fn check_bulk_insert_physical_region_passthrough(
379 skip_wal: bool,
380 close: bool,
381 retain_partition_columns: bool,
382 ) -> String {
383 let mito_config = MitoConfig {
385 default_flat_format: true,
386 ..Default::default()
387 };
388 let mut env = TestEnv::with_mito_config("", mito_config, Default::default()).await;
389 env.init_metric_region().await;
390 env.metric().inner.flush_task.stop().await.unwrap();
391 let physical_region_id = env.default_physical_region_id();
392 let logical_region_id = env.default_logical_region_id();
393
394 let logical_batch = build_logical_batch(0, 3);
396 let logical_request = build_bulk_request(logical_region_id, logical_batch, skip_wal);
397 let response = env
398 .metric()
399 .handle_request(logical_region_id, logical_request)
400 .await
401 .unwrap();
402 assert_eq!(response.affected_rows, 3);
403
404 let tag_columns = vec![TagColumnInfo {
408 name: "job".to_string(),
409 index: 2,
410 column_id: 2, }];
412 let non_tag_indices = if retain_partition_columns {
413 vec![0, 1, 2] } else {
415 vec![0, 1] };
417 let second_batch = build_logical_batch(3, 3);
418 let physical_batch = modify_batch_sparse(
419 second_batch,
420 logical_region_id.table_id(),
421 &tag_columns,
422 &non_tag_indices,
423 )
424 .unwrap();
425 assert_eq!(
426 physical_batch.column_by_name("job").is_some(),
427 retain_partition_columns
428 );
429 let request = build_bulk_request(physical_region_id, physical_batch, skip_wal);
430 let response = env
431 .metric()
432 .handle_request(physical_region_id, request)
433 .await
434 .unwrap();
435 assert_eq!(response.affected_rows, 3);
436
437 let stream = env
439 .metric()
440 .scan_to_stream(logical_region_id, ScanRequest::default())
441 .await
442 .unwrap();
443 let batches = RecordBatches::try_collect(stream).await.unwrap();
444 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 6);
445
446 let stat = env.mito().region_statistic(physical_region_id).unwrap();
447 assert_eq!(stat.sst_num, 0);
448 let reopened = if close {
449 env.metric()
450 .handle_request(
451 physical_region_id,
452 RegionRequest::Close(RegionCloseRequest::default()),
453 )
454 .await
455 .unwrap();
456 MetricEngine::try_new(env.mito(), Default::default()).unwrap()
458 } else {
459 env.reopen_engine(Default::default()).await;
461 env.metric()
462 };
463 reopened.inner.flush_task.stop().await.unwrap();
464 reopened
465 .handle_request(
466 physical_region_id,
467 RegionRequest::Open(RegionOpenRequest {
468 engine: METRIC_ENGINE_NAME.to_string(),
469 table_dir: TestEnv::default_table_dir(),
470 path_type: PathType::Bare,
471 options: [(PHYSICAL_TABLE_METADATA_KEY.to_string(), String::new())]
472 .into_iter()
473 .collect(),
474 skip_wal_replay: false,
475 checkpoint: None,
476 requirements: Default::default(),
477 }),
478 )
479 .await
480 .unwrap();
481 let stat = env
482 .mito()
483 .region_statistic(crate::utils::to_data_region_id(physical_region_id))
484 .unwrap();
485 assert_eq!(stat.sst_num > 0, skip_wal && close);
486 let stream = reopened
487 .scan_to_stream(logical_region_id, ScanRequest::default())
488 .await
489 .unwrap();
490 let batches = RecordBatches::try_collect(stream).await.unwrap();
491 assert_eq!(
492 batches.iter().map(|b| b.num_rows()).sum::<usize>(),
493 if skip_wal && !close { 0 } else { 6 },
494 );
495 batches.pretty_print().unwrap()
496 }
497
498 #[tokio::test]
499 async fn test_bulk_insert_physical_region_empty_batch() {
500 let mito_config = MitoConfig {
502 default_flat_format: true,
503 ..Default::default()
504 };
505 let env = TestEnv::with_mito_config("", mito_config, Default::default()).await;
506 env.init_metric_region().await;
507 let physical_region_id = env.default_physical_region_id();
508
509 let batch = build_logical_batch(0, 0);
510 let request = build_bulk_request(physical_region_id, batch, false);
511 let response = env
512 .metric()
513 .handle_request(physical_region_id, request)
514 .await
515 .unwrap();
516 assert_eq!(response.affected_rows, 0);
517 }
518
519 #[tokio::test]
520 async fn test_bulk_insert_unknown_column_errors() {
521 let env = TestEnv::new().await;
522 env.init_metric_region().await;
523 let logical_region_id = env.default_logical_region_id();
524
525 let schema = Arc::new(ArrowSchema::new(vec![
526 Field::new(
527 greptime_timestamp(),
528 DataType::Timestamp(TimeUnit::Millisecond, None),
529 false,
530 ),
531 Field::new(greptime_value(), DataType::Float64, true),
532 Field::new("nonexistent_column", DataType::Utf8, true),
533 ]));
534 let batch = RecordBatch::try_new(
535 schema,
536 vec![
537 Arc::new(TimestampMillisecondArray::from(vec![0i64])),
538 Arc::new(Float64Array::from(vec![1.0])),
539 Arc::new(StringArray::from(vec!["val"])),
540 ],
541 )
542 .unwrap();
543
544 let request = build_bulk_request(logical_region_id, batch, false);
545 let err = env
546 .metric()
547 .handle_request(logical_region_id, request)
548 .await
549 .unwrap_err();
550 let Some(err) = err.as_any().downcast_ref::<Error>() else {
551 panic!("unexpected error type");
552 };
553 assert_matches!(err, Error::ColumnNotFound { .. });
554 }
555
556 #[tokio::test]
557 async fn test_bulk_insert_multiple_tag_columns() {
558 let env = TestEnv::new().await;
559 let physical_region_id = env.default_physical_region_id();
560 env.create_physical_region(physical_region_id, &TestEnv::default_table_dir(), vec![])
561 .await;
562 let logical_region_id = env.default_logical_region_id();
563 let request = test_util::create_logical_region_request(
564 &["host", "region"],
565 physical_region_id,
566 &table_dir("test", logical_region_id.table_id()),
567 );
568 env.metric()
569 .handle_request(logical_region_id, RegionRequest::Create(request))
570 .await
571 .unwrap();
572
573 let schema = Arc::new(ArrowSchema::new(vec![
574 Field::new(
575 greptime_timestamp(),
576 DataType::Timestamp(TimeUnit::Millisecond, None),
577 false,
578 ),
579 Field::new(greptime_value(), DataType::Float64, true),
580 Field::new("host", DataType::Utf8, true),
581 Field::new("region", DataType::Utf8, true),
582 ]));
583 let batch = RecordBatch::try_new(
584 schema,
585 vec![
586 Arc::new(TimestampMillisecondArray::from(vec![0i64, 1, 2])),
587 Arc::new(Float64Array::from(vec![10.0, 20.0, 30.0])),
588 Arc::new(StringArray::from(vec!["h1", "h2", "h1"])),
589 Arc::new(StringArray::from(vec!["us-east", "us-west", "eu-west"])),
590 ],
591 )
592 .unwrap();
593
594 let request = build_bulk_request(logical_region_id, batch, false);
595 let response = env
596 .metric()
597 .handle_request(logical_region_id, request)
598 .await
599 .unwrap();
600 assert_eq!(response.affected_rows, 3);
601
602 let stream = env
603 .metric()
604 .scan_to_stream(logical_region_id, ScanRequest::default())
605 .await
606 .unwrap();
607 let batches = RecordBatches::try_collect(stream).await.unwrap();
608 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 3);
609 }
610
611 #[tokio::test]
612 async fn test_bulk_insert_accumulates_rows() {
613 let env = TestEnv::new().await;
614 env.init_metric_region().await;
615 let logical_region_id = env.default_logical_region_id();
616
617 let request = build_bulk_request(logical_region_id, build_logical_batch(0, 3), false);
618 let response = env
619 .metric()
620 .handle_request(logical_region_id, request)
621 .await
622 .unwrap();
623 assert_eq!(response.affected_rows, 3);
624
625 let request = build_bulk_request(logical_region_id, build_logical_batch(3, 5), false);
626 let response = env
627 .metric()
628 .handle_request(logical_region_id, request)
629 .await
630 .unwrap();
631 assert_eq!(response.affected_rows, 5);
632
633 let stream = env
634 .metric()
635 .scan_to_stream(logical_region_id, ScanRequest::default())
636 .await
637 .unwrap();
638 let batches = RecordBatches::try_collect(stream).await.unwrap();
639 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 8);
640 }
641
642 #[tokio::test]
643 async fn test_bulk_insert_sparse_encoding() {
644 let env = TestEnv::new().await;
645 let physical_region_id = env.default_physical_region_id();
646 env.create_physical_region(
647 physical_region_id,
648 &TestEnv::default_table_dir(),
649 vec![(
650 EXPERIMENTAL_SST_FLOAT_FIELD_ENCODING.to_string(),
651 "byte_stream_split".to_string(),
652 )],
653 )
654 .await;
655 let logical_region_id = env.default_logical_region_id();
656 env.create_logical_region(physical_region_id, logical_region_id)
657 .await;
658
659 let rows = 4;
660 let request = build_bulk_request(logical_region_id, build_logical_batch(0, rows), false);
661 let response = env
662 .metric()
663 .handle_request(logical_region_id, request)
664 .await
665 .unwrap();
666 assert_eq!(response.affected_rows, rows);
667
668 let data_region_id = crate::utils::to_data_region_id(physical_region_id);
669 env.mito()
670 .handle_request(
671 data_region_id,
672 RegionRequest::Flush(RegionFlushRequest::default()),
673 )
674 .await
675 .unwrap();
676
677 let region = env.mito().find_region(data_region_id).unwrap();
678 let entry = region
679 .manifest_sst_entries()
680 .await
681 .into_iter()
682 .find(|entry| entry.visible && entry.file_path.ends_with(".parquet"))
683 .unwrap();
684 let mut cache_metrics = MetadataCacheMetrics::default();
685 let footer = MetadataLoader::new(
686 region.access_layer().object_store().clone(),
687 &entry.file_path,
688 entry.file_size,
689 )
690 .load(&mut cache_metrics)
691 .await
692 .unwrap();
693 let field_column_index = footer
694 .file_metadata()
695 .schema_descr()
696 .columns()
697 .iter()
698 .position(|column| column.name() == greptime_value())
699 .unwrap();
700 assert!(!footer.row_groups().is_empty());
701 assert!(footer.row_groups().iter().all(|row_group| {
702 row_group
703 .column(field_column_index)
704 .encodings()
705 .any(|encoding| encoding == Encoding::BYTE_STREAM_SPLIT)
706 }));
707
708 let stream = env
709 .metric()
710 .scan_to_stream(logical_region_id, ScanRequest::default())
711 .await
712 .unwrap();
713 let batches = RecordBatches::try_collect(stream).await.unwrap();
714 assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), rows);
715 }
716
717 #[tokio::test]
718 async fn test_bulk_insert_dense_encoding_rejected() {
719 let env = TestEnv::new().await;
720 let logical_region_id = init_dense_metric_region(&env).await;
721
722 let request = build_bulk_request(logical_region_id, build_logical_batch(0, 2), false);
723 let err = env
724 .metric()
725 .handle_request(logical_region_id, request)
726 .await
727 .unwrap_err();
728 let Some(err) = err.as_any().downcast_ref::<Error>() else {
729 panic!("unexpected error type");
730 };
731 assert_matches!(err, Error::UnsupportedRegionRequest { .. });
732 }
733
734 #[tokio::test]
735 async fn test_bulk_insert_matches_put() {
736 let env_put = TestEnv::new().await;
737 env_put.init_metric_region().await;
738 let logical_region_id = env_put.default_logical_region_id();
739 let schema = test_util::row_schema_with_tags(&["job"]);
740 let rows = test_util::build_rows(1, 5);
741 env_put
742 .metric()
743 .handle_request(
744 logical_region_id,
745 RegionRequest::Put(RegionPutRequest {
746 skip_wal: false,
747 rows: api::v1::Rows { schema, rows },
748 hint: None,
749 partition_expr_version: None,
750 }),
751 )
752 .await
753 .unwrap();
754 let put_stream = env_put
755 .metric()
756 .scan_to_stream(logical_region_id, ScanRequest::default())
757 .await
758 .unwrap();
759 let put_batches = RecordBatches::try_collect(put_stream).await.unwrap();
760 let put_output = put_batches.pretty_print().unwrap();
761
762 let env_bulk = TestEnv::new().await;
763 env_bulk.init_metric_region().await;
764 let request = build_bulk_request(logical_region_id, build_logical_batch(0, 5), false);
765 env_bulk
766 .metric()
767 .handle_request(logical_region_id, request)
768 .await
769 .unwrap();
770 let bulk_stream = env_bulk
771 .metric()
772 .scan_to_stream(logical_region_id, ScanRequest::default())
773 .await
774 .unwrap();
775 let bulk_batches = RecordBatches::try_collect(bulk_stream).await.unwrap();
776 let bulk_output = bulk_batches.pretty_print().unwrap();
777
778 assert_eq!(put_output, bulk_output);
779 }
780}