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