Skip to main content

operator/statement/
export_logical_tables.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Internal physical-table export into logical-table Parquet files.
16//!
17//! Callers must authorize the captured tables and destination before invoking
18//! this component, and keep the source stable. Each destination must be owned
19//! exclusively by the export attempt. Completion and cleanup of closed files
20//! belong to the caller's chunk protocol, not individual file completion.
21
22pub(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/// Export preprocessing and per-file limits. Query memory and spill remain
65/// governed by the query engine.
66/// Batch checks happen after allocation; flush thresholds are not hard RSS caps.
67#[derive(Clone, Copy, Debug)]
68pub struct LogicalTableExportLimits {
69    /// Maximum retained scan batch before routing.
70    pub input_batch_bytes: usize,
71    /// Maximum estimated expanded logical values in one conversion slice.
72    pub conversion_bytes: usize,
73    /// Limits owned by the single-file Parquet writer.
74    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
103/// Metadata captured for one physical table and its selected logical tables.
104/// Construct one unit per physical table, schema and time chunk. Capturing these
105/// references supplies no snapshot isolation or locking guarantee.
106pub 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    /// Capture schemas from selected Metric table references.
121    /// Export validates their physical-table association against table routes.
122    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        // A single selected table has a constant routing key after filtering.
293        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/// Counts returned only after every selected logical file has been closed.
303#[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    /// Export selected logical tables to a fresh directory using one physical query.
312    /// The caller bounds concurrent units and owns retry/cleanup of this directory.
313    /// Cancellation waits for in-flight I/O before aborting the active upload.
314    /// Closed files remain an incomplete chunk until the caller publishes its
315    /// completion state.
316    #[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    // Parquet accepts compatible string dictionaries without expanding their values.
625    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    // Sharing compatible arrays needs no CPU conversion. Keep the size check on
650    // this task only when its dictionary inspection is bounded.
651    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
703/// Limits nested casts to selected children: Arrow's List cast otherwise expands
704/// the entire values array, even when the parent has been sliced.
705pub(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                    // Select dictionary entries before recursively converting nested values.
724                    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
794// Count only selected logical values before dictionary expansion, including nested
795// histogram lists/structs. Offset and validity overhead is charged per value.
796fn 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
849/// Estimates a conservative byte upper bound with bounded work on an async task.
850/// `None` means this path cannot estimate the batch, not that it exceeds a budget.
851fn 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                    // JSON escaping and number formatting only expand JSON values.
907                    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;