1pub(crate) mod writers;
23
24use std::collections::{BTreeMap, BTreeSet, HashMap};
25use std::sync::Arc;
26
27use arrow::array::{Array, ArrayRef, AsArray, ListArray, StructArray, UInt32Array};
28use arrow::buffer::OffsetBuffer;
29use arrow::compute::{cast, take};
30use arrow::datatypes::{DataType, SchemaRef};
31use arrow::downcast_dictionary_array;
32use arrow::record_batch::RecordBatch;
33use common_datasource::object_store::build_backend_for_write;
34use common_datasource::packed_writer::{PackedTableWriter, PackedWriterRef};
35use common_datasource::parquet_writer::{
36 ParquetCreationPolicy, ParquetFileWriter, ParquetWriterLimits,
37};
38use common_meta::key::table_route::{TableRouteManager, TableRouteValue};
39use common_query::OutputData;
40use common_recordbatch::SendableRecordBatchStream;
41use common_time::range::TimestampRange;
42use datafusion::datasource::DefaultTableSource;
43use datafusion_common::TableReference as DfTableReference;
44use datafusion_expr::{LogicalPlan, LogicalPlanBuilder, col, lit};
45use futures::StreamExt;
46use object_store::ObjectStore;
47use session::context::QueryContextRef;
48use snafu::{IntoError, OptionExt, ResultExt, ensure};
49use store_api::metric_engine_consts::{
50 DATA_SCHEMA_TABLE_ID_COLUMN_NAME as TABLE_ID, DATA_SCHEMA_TSID_COLUMN_NAME as TSID,
51 LOGICAL_TABLE_METADATA_KEY, METRIC_ENGINE_NAME, PHYSICAL_TABLE_METADATA_KEY,
52};
53use table::TableRef;
54use table::metadata::TableId;
55use table::table::adapter::DfTableProviderAdapter;
56use tokio::sync::OwnedSemaphorePermit;
57use tokio_util::sync::CancellationToken;
58
59use crate::error::{self, InvalidLogicalTableExportSnafu, LogicalTableExportResourceSnafu, Result};
60use crate::statement::StatementExecutor;
61use crate::statement::database_copy::DatabaseExportFile;
62use crate::statement::export_logical_tables::writers::{ExportWriteBudget, Payload, TableWriters};
63
64#[derive(Clone, Copy, Debug)]
68pub struct LogicalTableExportLimits {
69 pub input_batch_bytes: usize,
71 pub conversion_bytes: usize,
73 pub writer: ParquetWriterLimits,
75}
76
77impl Default for LogicalTableExportLimits {
78 fn default() -> Self {
79 Self {
80 input_batch_bytes: 64 * 1024 * 1024,
81 conversion_bytes: 1024 * 1024,
82 writer: ParquetWriterLimits {
83 row_group_rows: 1024 * 1024,
84 flush_threshold_bytes: 8 * 1024 * 1024,
85 max_row_groups: 4096,
86 },
87 }
88 }
89}
90
91impl LogicalTableExportLimits {
92 fn validate(self) -> Result<()> {
93 ensure!(
94 self.input_batch_bytes > 0 && self.conversion_bytes > 0,
95 InvalidLogicalTableExportSnafu {
96 reason: "export limits must be positive"
97 }
98 );
99 Ok(())
100 }
101}
102
103pub struct LogicalTableExport {
107 physical_table: TableRef,
108 scan_projection: Vec<usize>,
109 logical_tables: BTreeMap<TableId, LogicalTableProjection>,
110}
111
112pub(crate) struct LogicalTableProjection {
113 name: String,
114 output: DatabaseExportFile,
115 schema: SchemaRef,
116 projection: Vec<usize>,
117}
118
119impl LogicalTableExport {
120 pub fn try_new(physical: TableRef, tables: &[TableRef]) -> Result<Self> {
123 Self::try_new_in_directory(physical, tables, "")
124 }
125
126 pub(crate) fn try_new_in_directory(
127 physical: TableRef,
128 tables: &[TableRef],
129 directory: &str,
130 ) -> Result<Self> {
131 let physical_info = physical.table_info();
132 ensure!(
133 physical_info.meta.engine == METRIC_ENGINE_NAME
134 && physical_info
135 .meta
136 .options
137 .extra_options
138 .contains_key(PHYSICAL_TABLE_METADATA_KEY)
139 && !tables.is_empty(),
140 InvalidLogicalTableExportSnafu {
141 reason: "expected a Metric physical table and selected logical tables"
142 }
143 );
144 let physical_schema = physical.schema();
145 let id_index = physical_schema.column_index_by_name(TABLE_ID).context(
146 InvalidLogicalTableExportSnafu {
147 reason: "physical schema has no __table_id",
148 },
149 )?;
150 let mut scan_projection = BTreeSet::from([id_index]);
151 let mut logical_tables = BTreeMap::new();
152 for table in tables {
153 let info = table.table_info();
154 let name = &info.name;
155 ensure!(
156 info.catalog_name == physical_info.catalog_name
157 && info.schema_name == physical_info.schema_name
158 && info.meta.engine == METRIC_ENGINE_NAME
159 && info
160 .meta
161 .options
162 .extra_options
163 .contains_key(LOGICAL_TABLE_METADATA_KEY),
164 InvalidLogicalTableExportSnafu {
165 reason: format!(
166 "{name} is not a Metric logical table in the physical table schema"
167 )
168 }
169 );
170 let schema = table.schema().arrow_schema().clone();
171 let indices = schema
172 .fields()
173 .iter()
174 .map(|field| {
175 ensure!(
176 field.name() != TABLE_ID && field.name() != TSID,
177 InvalidLogicalTableExportSnafu {
178 reason: "logical schema contains internal Metric columns"
179 }
180 );
181 let index = physical_schema
182 .column_index_by_name(field.name())
183 .with_context(|| InvalidLogicalTableExportSnafu {
184 reason: format!("physical schema lacks {}", field.name()),
185 })?;
186 ensure!(
187 physical_schema.arrow_schema().field(index).data_type()
188 == field.data_type(),
189 InvalidLogicalTableExportSnafu {
190 reason: format!("physical/logical type mismatch for {}", field.name())
191 }
192 );
193 Ok(index)
194 })
195 .collect::<Result<Vec<_>>>()?;
196 scan_projection.extend(indices.iter().copied());
197 ensure!(
198 logical_tables
199 .insert(
200 info.table_id(),
201 LogicalTableProjection {
202 name: name.clone(),
203 output: DatabaseExportFile::new(directory, name, ".parquet")?,
204 schema,
205 projection: indices,
206 }
207 )
208 .is_none(),
209 InvalidLogicalTableExportSnafu {
210 reason: "duplicate logical table"
211 }
212 );
213 }
214 let scan_projection = scan_projection.into_iter().collect::<Vec<_>>();
215 for file in logical_tables.values_mut() {
216 for index in &mut file.projection {
217 *index = scan_projection.binary_search(index).map_err(|_| {
218 error::UnexpectedSnafu {
219 violated: "logical column missing from physical projection",
220 }
221 .build()
222 })?;
223 }
224 }
225 Ok(Self {
226 physical_table: physical,
227 scan_projection,
228 logical_tables,
229 })
230 }
231
232 pub(crate) fn output_files(&self) -> impl Iterator<Item = &DatabaseExportFile> {
233 self.logical_tables.values().map(|table| &table.output)
234 }
235
236 async fn validate_table_routes(&self, manager: &TableRouteManager) -> Result<()> {
237 let table_ids = self.logical_tables.keys().copied().collect::<Vec<_>>();
238 let routes = manager
239 .table_route_storage()
240 .batch_get(&table_ids)
241 .await
242 .context(error::TableMetadataManagerSnafu)?;
243 let physical_table_id = self.physical_table.table_info().table_id();
244 for (table_id, route) in table_ids.into_iter().zip(routes) {
245 ensure!(
246 matches!(route, Some(TableRouteValue::Logical(route)) if route.physical_table_id() == physical_table_id),
247 InvalidLogicalTableExportSnafu {
248 reason: format!(
249 "logical table {table_id} does not belong to physical table {physical_table_id}"
250 )
251 }
252 );
253 }
254 Ok(())
255 }
256
257 fn build_plan(&self, time_range: Option<&TimestampRange>) -> Result<LogicalPlan> {
258 let info = self.physical_table.table_info();
259 let mut filters = self
260 .physical_table
261 .schema()
262 .timestamp_column()
263 .and_then(|column| {
264 common_query::logical_plan::build_filter_from_timestamp(&column.name, time_range)
265 })
266 .into_iter()
267 .collect::<Vec<_>>();
268 if self.logical_tables.len() == 1
269 && let Some((&id, _)) = self.logical_tables.first_key_value()
270 {
271 filters.push(col(TABLE_ID).eq(lit(id)));
272 }
273 let source = Arc::new(DefaultTableSource::new(Arc::new(
274 DfTableProviderAdapter::new(self.physical_table.clone()),
275 )));
276 let mut builder = LogicalPlanBuilder::scan_with_filters(
277 DfTableReference::full(
278 info.catalog_name.clone(),
279 info.schema_name.clone(),
280 info.name.clone(),
281 ),
282 source,
283 Some(self.scan_projection.clone()),
284 filters.clone(),
285 )
286 .context(error::BuildDfLogicalPlanSnafu)?;
287 for filter in filters {
288 builder = builder
289 .filter(filter)
290 .context(error::BuildDfLogicalPlanSnafu)?;
291 }
292 if self.logical_tables.len() > 1 {
294 builder = builder
295 .sort(vec![col(TABLE_ID).sort(true, false)])
296 .context(error::BuildDfLogicalPlanSnafu)?;
297 }
298 builder.build().context(error::BuildDfLogicalPlanSnafu)
299 }
300}
301
302#[derive(Debug, Default, PartialEq, Eq)]
304pub struct LogicalTableExportSummary {
305 pub rows: usize,
306 pub skipped_rows: usize,
307 pub files: usize,
308}
309
310impl StatementExecutor {
311 #[allow(clippy::too_many_arguments)]
317 pub async fn export_logical_tables(
318 &self,
319 unit: &LogicalTableExport,
320 directory: &str,
321 connection: &HashMap<String, String>,
322 time_range: Option<&TimestampRange>,
323 limits: LogicalTableExportLimits,
324 cancellation: &CancellationToken,
325 query_ctx: QueryContextRef,
326 ) -> Result<LogicalTableExportSummary> {
327 self.export_logical_tables_managed(
328 unit,
329 directory,
330 connection,
331 time_range,
332 limits,
333 &cancellation.child_token(),
334 query_ctx,
335 ExportWriteBudget::new(1),
336 None,
337 )
338 .await
339 }
340
341 #[allow(clippy::too_many_arguments)]
342 pub(crate) async fn export_logical_tables_managed(
343 &self,
344 unit: &LogicalTableExport,
345 directory: &str,
346 connection: &HashMap<String, String>,
347 time_range: Option<&TimestampRange>,
348 limits: LogicalTableExportLimits,
349 cancellation: &CancellationToken,
350 query_ctx: QueryContextRef,
351 budget: Arc<ExportWriteBudget>,
352 packed: Option<PackedWriterRef>,
353 ) -> Result<LogicalTableExportSummary> {
354 limits.validate()?;
355 let (store, stream) = tokio::select! {
356 biased;
357 _ = cancellation.cancelled() => return error::LogicalTableExportCancelledSnafu.fail(),
358 result = async {
359 unit.validate_table_routes(self.table_metadata_manager.table_route_manager()).await?;
360 let store = build_backend_for_write(&format!("{}/", directory.trim_end_matches('/')), connection, &self.local_file_access)
361 .await.context(error::BuildBackendSnafu)?;
362 let output = self.query_engine.execute(unit.build_plan(time_range)?, query_ctx)
363 .await.context(error::ExecLogicalPlanSnafu)?;
364 let stream = match output.data {
365 OutputData::Stream(stream) => stream,
366 OutputData::RecordBatches(batches) => batches.as_stream(),
367 _ => return error::UnexpectedSnafu { violated: "expected physical query rows" }.fail(),
368 };
369 Ok((store, stream))
370 } => result?,
371 };
372 export_stream_managed(unit, stream, &store, limits, cancellation, budget, packed).await
373 }
374}
375
376#[cfg(test)]
377async fn export_stream(
378 unit: &LogicalTableExport,
379 stream: SendableRecordBatchStream,
380 store: &ObjectStore,
381 limits: LogicalTableExportLimits,
382 cancellation: &CancellationToken,
383) -> Result<LogicalTableExportSummary> {
384 export_stream_managed(
385 unit,
386 stream,
387 store,
388 limits,
389 &cancellation.child_token(),
390 ExportWriteBudget::new(1),
391 None,
392 )
393 .await
394}
395
396async fn export_stream_managed(
397 unit: &LogicalTableExport,
398 stream: SendableRecordBatchStream,
399 store: &ObjectStore,
400 limits: LogicalTableExportLimits,
401 cancellation: &CancellationToken,
402 budget: Arc<ExportWriteBudget>,
403 packed: Option<PackedWriterRef>,
404) -> Result<LogicalTableExportSummary> {
405 let mut writers = TableWriters::new(budget.clone());
406 writers.packed = packed;
407 let result = write_tables(
408 unit,
409 stream,
410 store,
411 limits,
412 cancellation,
413 &mut writers,
414 &budget,
415 )
416 .await;
417 let (summary, result) = match result {
418 Ok(summary) => (summary, Ok(())),
419 Err(error) => (LogicalTableExportSummary::default(), Err(error)),
420 };
421 writers.drain(result, cancellation).await?;
422 Ok(summary)
423}
424
425fn check_cancelled(cancellation: &CancellationToken) -> Result<()> {
426 ensure!(
427 !cancellation.is_cancelled(),
428 error::LogicalTableExportCancelledSnafu
429 );
430 Ok(())
431}
432
433async fn write_tables(
434 unit: &LogicalTableExport,
435 mut stream: SendableRecordBatchStream,
436 store: &ObjectStore,
437 limits: LogicalTableExportLimits,
438 cancellation: &CancellationToken,
439 writers: &mut TableWriters,
440 budget: &ExportWriteBudget,
441) -> Result<LogicalTableExportSummary> {
442 let id_index =
443 stream
444 .schema()
445 .column_index_by_name(TABLE_ID)
446 .context(error::UnexpectedSnafu {
447 violated: "physical query omitted __table_id",
448 })?;
449 let mut summary = LogicalTableExportSummary::default();
450 let mut previous = None;
451 let mut written = BTreeSet::new();
452 loop {
453 let batch = tokio::select! {
454 biased;
455 _ = cancellation.cancelled() => return error::LogicalTableExportCancelledSnafu.fail(),
456 batch = stream.next() => batch,
457 };
458 let Some(batch) = batch else {
459 break;
460 };
461 let batch = batch
462 .context(error::BuildRecordBatchSnafu)?
463 .into_df_record_batch();
464 ensure!(
465 batch.get_array_memory_size() <= limits.input_batch_bytes,
466 LogicalTableExportResourceSnafu {
467 reason: "scan batch exceeds input byte budget"
468 }
469 );
470 let ids = batch
471 .column(id_index)
472 .as_any()
473 .downcast_ref::<UInt32Array>()
474 .context(error::UnexpectedSnafu {
475 violated: "__table_id must be UInt32",
476 })?;
477 ensure!(
478 ids.null_count() == 0,
479 error::UnexpectedSnafu {
480 violated: "null __table_id"
481 }
482 );
483 let mut start = 0;
484 while start < batch.num_rows() {
485 check_cancelled(cancellation)?;
486 let id = ids.value(start);
487 ensure!(
488 previous.is_none_or(|last| last <= id),
489 error::UnexpectedSnafu {
490 violated: "physical query is not ordered by __table_id"
491 }
492 );
493 previous = Some(id);
494 let mut end = start + 1;
495 while end < batch.num_rows() && ids.value(end) == id {
496 end += 1;
497 }
498 if writers.table_id().is_some_and(|last| last != id) {
499 writers.close_input();
500 }
501 check_cancelled(cancellation)?;
502 if let Some(file) = unit.logical_tables.get(&id) {
503 if writers.table_id().is_none() {
504 writers.open(id, file, store, limits, cancellation).await?;
505 written.insert(id);
506 summary.files += 1;
507 }
508 let projected = batch
509 .project(&file.projection)
510 .context(error::ProjectSchemaSnafu)?;
511 let mut offset = start;
512 while offset < end {
513 let (expanded, consumed) = expand_bounded_slice(
514 projected.clone(),
515 file.schema.clone(),
516 offset,
517 end,
518 limits.conversion_bytes,
519 budget,
520 cancellation,
521 )
522 .await?;
523 check_cancelled(cancellation)?;
524 writers.send(expanded, cancellation).await?;
525 check_cancelled(cancellation)?;
526 offset += consumed;
527 summary.rows += consumed;
528 }
529 } else {
530 summary.skipped_rows += end - start;
531 }
532 start = end;
533 }
534 }
535 writers.close_input();
536 for (&id, file) in &unit.logical_tables {
537 if !written.contains(&id) {
538 check_cancelled(cancellation)?;
539 writers.open(id, file, store, limits, cancellation).await?;
540 writers.close_input();
541 summary.files += 1;
542 }
543 }
544 check_cancelled(cancellation)?;
545 Ok(summary)
546}
547
548struct ActiveWriter {
549 path: String,
550 writer: ParquetFileWriter,
551}
552
553impl ActiveWriter {
554 fn open_packed(
555 table: &LogicalTableProjection,
556 id: u32,
557 store: &ObjectStore,
558 limits: LogicalTableExportLimits,
559 shared: PackedWriterRef,
560 ) -> Result<Self> {
561 let path = format!("table-{id}.parquet");
562 let packed = PackedTableWriter::new(shared, table.name.clone(), id, false);
563 let writer = ParquetFileWriter::open_packed(
564 table.schema.clone(),
565 store.clone(),
566 &path,
567 Some(limits.writer),
568 packed,
569 )
570 .map_err(|e| map_writer_error(e, &path))?;
571 Ok(Self { path, writer })
572 }
573
574 async fn open(
575 table: &LogicalTableProjection,
576 store: &ObjectStore,
577 limits: LogicalTableExportLimits,
578 ) -> Result<Self> {
579 let path = table.output.path.clone();
580 let conditional = store.info().capability().write_with_if_not_exists;
581 if !conditional {
582 ensure!(
583 !store
584 .exists(&path)
585 .await
586 .context(error::ReadObjectSnafu { path: &path })?,
587 InvalidLogicalTableExportSnafu {
588 reason: format!("output already exists: {path}")
589 }
590 );
591 }
592 let writer = ParquetFileWriter::open_with_creation(
593 table.schema.clone(),
594 store.clone(),
595 &path,
596 1,
597 Some(limits.writer),
598 if conditional {
599 ParquetCreationPolicy::IfNotExists
600 } else {
601 ParquetCreationPolicy::Overwrite
602 },
603 )
604 .await
605 .map_err(|error| map_writer_error(error, &path))?;
606 Ok(Self { path, writer })
607 }
608}
609
610async fn expand_bounded_slice(
611 batch: RecordBatch,
612 schema: SchemaRef,
613 start: usize,
614 end: usize,
615 requested: usize,
616 budget: &ExportWriteBudget,
617 cancellation: &CancellationToken,
618) -> Result<(Payload, usize)> {
619 let (conversion, retained) = ExportWriteBudget::conversion_budget(
620 batch.get_array_memory_size(),
621 batch.num_columns(),
622 requested,
623 )?;
624 let fields = schema
626 .fields()
627 .iter()
628 .zip(batch.columns())
629 .map(|(field, array)| {
630 if let DataType::Dictionary(_, value) = array.data_type()
631 && value.as_ref() == field.data_type()
632 && matches!(value.as_ref(), DataType::Utf8 | DataType::LargeUtf8)
633 {
634 Arc::new(
635 field
636 .as_ref()
637 .clone()
638 .with_data_type(array.data_type().clone()),
639 )
640 } else {
641 field.clone()
642 }
643 })
644 .collect::<Vec<_>>();
645 let schema = Arc::new(arrow::datatypes::Schema::new_with_metadata(
646 fields,
647 schema.metadata().clone(),
648 ));
649 if start < end
652 && batch
653 .columns()
654 .iter()
655 .zip(schema.fields())
656 .all(|(array, field)| array.data_type() == field.data_type())
657 && let Some(estimated) = estimate_batch_bytes_upper_bound(&batch, end - start, &[])?
658 && estimated <= conversion
659 {
660 let reservation = retained.saturating_add(estimated.saturating_mul(4));
661 let permit = budget.reserve(reservation, cancellation).await?;
662 return convert_slice_payload(batch, schema, start, end - start, reservation, permit);
663 }
664 let input = batch.clone();
665 let (len, estimated) = common_runtime::spawn_blocking_global(move || {
666 rows_within_budget(&input, start, end, conversion, &[])
667 })
668 .await
669 .context(error::JoinTaskSnafu)??;
670 let reservation = retained.saturating_add(estimated.saturating_mul(4));
671 let permit = budget.reserve(reservation, cancellation).await?;
672 common_runtime::spawn_blocking_global(move || {
673 convert_slice_payload(batch, schema, start, len, reservation, permit)
674 })
675 .await
676 .context(error::JoinTaskSnafu)?
677}
678
679fn convert_slice_payload(
680 batch: RecordBatch,
681 schema: SchemaRef,
682 start: usize,
683 len: usize,
684 reservation: usize,
685 permit: OwnedSemaphorePermit,
686) -> Result<(Payload, usize)> {
687 let expanded = expand_export_batch(&batch.slice(start, len), schema)?;
688 ensure!(
689 expanded.get_array_memory_size() <= reservation,
690 LogicalTableExportResourceSnafu {
691 reason: "converted backing buffers exceed reservation"
692 }
693 );
694 Ok((
695 Payload {
696 batch: expanded,
697 permit,
698 },
699 len,
700 ))
701}
702
703pub(crate) fn expand_export_batch(batch: &RecordBatch, schema: SchemaRef) -> Result<RecordBatch> {
706 let arrays = batch
707 .columns()
708 .iter()
709 .zip(schema.fields())
710 .map(|(array, field)| expand_export_array(array, field.data_type()))
711 .collect::<Result<Vec<_>>>()?;
712 RecordBatch::try_new(schema, arrays).context(error::ComputeArrowSnafu)
713}
714
715fn expand_export_array(array: &ArrayRef, target: &DataType) -> Result<ArrayRef> {
716 if array.data_type() == target {
717 return Ok(array.clone());
718 }
719 match (array.data_type(), target) {
720 (DataType::Dictionary(_, _), _) => {
721 downcast_dictionary_array! {
722 array => {
723 let selected = take(array.values().as_ref(), array.keys(), None)
725 .context(error::ComputeArrowSnafu)?;
726 expand_export_array(&selected, target)
727 },
728 _ => error::UnexpectedSnafu { violated: "invalid dictionary array" }.fail(),
729 }
730 }
731 (DataType::List(_), DataType::List(field)) => {
732 let list = array.as_list::<i32>();
733 let offsets = list.value_offsets();
734 let start = offsets[0];
735 let end = offsets[list.len()];
736 let values = list.values().slice(start as usize, (end - start) as usize);
737 let values = expand_export_array(&values, field.data_type())?;
738 let offsets = OffsetBuffer::new(
739 offsets
740 .iter()
741 .map(|offset| offset - start)
742 .collect::<Vec<_>>()
743 .into(),
744 );
745 Ok(Arc::new(
746 ListArray::try_new(field.clone(), offsets, values, list.nulls().cloned())
747 .context(error::ComputeArrowSnafu)?,
748 ))
749 }
750 (DataType::Struct(_), DataType::Struct(fields)) => {
751 let array = array.as_struct();
752 let columns = array
753 .columns()
754 .iter()
755 .zip(fields)
756 .map(|(array, field)| expand_export_array(array, field.data_type()))
757 .collect::<Result<Vec<_>>>()?;
758 Ok(Arc::new(
759 StructArray::try_new(fields.clone(), columns, array.nulls().cloned())
760 .context(error::ComputeArrowSnafu)?,
761 ))
762 }
763 _ => cast(array, target).context(error::ComputeArrowSnafu),
764 }
765}
766
767pub(crate) fn map_writer_error(
768 source: common_datasource::error::Error,
769 path: &str,
770) -> error::Error {
771 match source {
772 common_datasource::error::Error::ParquetWriteCancelled {} => {
773 error::LogicalTableExportCancelledSnafu.build()
774 }
775 common_datasource::error::Error::InvalidParquetWriterLimits {} => {
776 InvalidLogicalTableExportSnafu {
777 reason: "Parquet writer limits must be positive",
778 }
779 .build()
780 }
781 common_datasource::error::Error::ParquetWriterResource { reason } => {
782 LogicalTableExportResourceSnafu { reason }.build()
783 }
784 common_datasource::error::Error::WriteObject { error, .. }
785 if error.kind() == object_store::ErrorKind::ConditionNotMatch =>
786 {
787 let reason = format!("output already exists: {path}");
788 InvalidLogicalTableExportSnafu { reason }.build()
789 }
790 source => error::WriteStreamToFileSnafu { path }.into_error(source),
791 }
792}
793
794fn estimate_value_size(array: &dyn Array, row: usize) -> Result<usize> {
797 if array.is_null(row) && !matches!(array.data_type(), DataType::Struct(_) | DataType::List(_)) {
798 return Ok(32);
799 }
800 let bytes = match array.data_type() {
801 DataType::Boolean => 1,
802 DataType::Null => 0,
803 DataType::Utf8 => array.as_string::<i32>().value(row).len(),
804 DataType::LargeUtf8 => array.as_string::<i64>().value(row).len(),
805 DataType::Utf8View => array.as_string_view().value(row).len(),
806 DataType::Binary => array.as_binary::<i32>().value(row).len(),
807 DataType::LargeBinary => array.as_binary::<i64>().value(row).len(),
808 DataType::BinaryView => array.as_binary_view().value(row).len(),
809 DataType::Struct(_) => {
810 array
811 .as_struct()
812 .columns()
813 .iter()
814 .try_fold(0usize, |sum, child| {
815 Ok::<_, error::Error>(
816 sum.saturating_add(estimate_value_size(child.as_ref(), row)?),
817 )
818 })?
819 }
820 DataType::List(_) => {
821 let list = array.as_list::<i32>();
822 let offsets = list.value_offsets();
823 (offsets[row] as usize..offsets[row + 1] as usize).try_fold(0usize, |sum, index| {
824 Ok::<_, error::Error>(
825 sum.saturating_add(estimate_value_size(list.values().as_ref(), index)?),
826 )
827 })?
828 }
829 DataType::Dictionary(_, _) => {
830 downcast_dictionary_array! {
831 array => {
832 match array.key(row) {
833 Some(index) => estimate_value_size(array.values().as_ref(), index)?,
834 None => 0,
835 }
836 },
837 _ => return error::UnexpectedSnafu { violated: "invalid dictionary array" }.fail(),
838 }
839 }
840 other => other
841 .primitive_width()
842 .with_context(|| InvalidLogicalTableExportSnafu {
843 reason: format!("unsupported Metric Parquet type: {other}"),
844 })?,
845 };
846 Ok(bytes.saturating_add(16))
847}
848
849fn estimate_batch_bytes_upper_bound(
852 batch: &RecordBatch,
853 rows: usize,
854 json_columns: &[usize],
855) -> Result<Option<usize>> {
856 let mut dictionary_values = 1024usize;
857 let mut max_row_bytes = 0usize;
858 for (index, array) in batch.columns().iter().enumerate() {
859 let size = if let Some(width) = array.data_type().primitive_width() {
860 (width + 16).max(32)
861 } else if let Some(dictionary) = array.as_any_dictionary_opt()
862 && dictionary.values().len() < rows
863 && dictionary.values().len() <= dictionary_values
864 && matches!(
865 dictionary.values().data_type(),
866 DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View
867 )
868 {
869 let values = dictionary.values();
870 dictionary_values -= values.len();
871 let max = (0..values.len()).try_fold(0usize, |max, row| {
872 Ok::<_, error::Error>(max.max(estimate_value_size(values.as_ref(), row)?))
873 })?;
874 max.saturating_add(16).max(32)
875 } else {
876 return Ok(None);
877 };
878 let expansion = if json_columns.contains(&index) { 8 } else { 1 };
879 max_row_bytes = max_row_bytes.saturating_add(size.saturating_mul(expansion));
880 }
881 Ok(max_row_bytes.checked_mul(rows))
882}
883
884pub(crate) fn rows_within_budget(
885 batch: &RecordBatch,
886 start: usize,
887 end: usize,
888 budget: usize,
889 json_columns: &[usize],
890) -> Result<(usize, usize)> {
891 if start < end
892 && let Some(bytes) = estimate_batch_bytes_upper_bound(batch, end - start, json_columns)?
893 && bytes <= budget
894 {
895 return Ok((end - start, bytes));
896 }
897 let mut bytes = 0usize;
898 let mut row = start;
899 while row < end {
900 let row_bytes =
901 batch
902 .columns()
903 .iter()
904 .enumerate()
905 .try_fold(0usize, |sum, (index, array)| {
906 let expansion = if json_columns.contains(&index) && !array.is_null(row) {
908 8
909 } else {
910 1
911 };
912 let size = estimate_value_size(array.as_ref(), row)?;
913 Ok::<_, error::Error>(sum.saturating_add(size.saturating_mul(expansion)))
914 })?;
915 if row_bytes > budget.saturating_sub(bytes) {
916 break;
917 }
918 bytes += row_bytes;
919 row += 1;
920 }
921 ensure!(
922 row > start,
923 LogicalTableExportResourceSnafu {
924 reason: "one expanded logical row exceeds conversion byte budget"
925 }
926 );
927 Ok((row - start, bytes))
928}
929
930#[cfg(test)]
931mod tests;