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};
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/// Export preprocessing and per-file limits. Query memory and spill remain
64/// governed by the query engine.
65/// Batch checks happen after allocation; flush thresholds are not hard RSS caps.
66#[derive(Clone, Copy, Debug)]
67pub struct LogicalTableExportLimits {
68    /// Maximum retained scan batch before routing.
69    pub input_batch_bytes: usize,
70    /// Maximum estimated expanded logical values in one conversion slice.
71    pub conversion_bytes: usize,
72    /// Limits owned by the single-file Parquet writer.
73    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
102/// Metadata captured for one physical table and its selected logical tables.
103/// Construct one unit per physical table, schema and time chunk. Capturing these
104/// references supplies no snapshot isolation or locking guarantee.
105pub 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    /// Capture schemas from selected Metric table references.
120    /// Export validates their physical-table association against table routes.
121    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/// Counts returned only after every selected logical file has been closed.
295#[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    /// Export selected logical tables to a fresh directory using one physical query.
304    /// The caller bounds concurrent units and owns retry/cleanup of this directory.
305    /// Cancellation waits for in-flight I/O before aborting the active upload.
306    /// Closed files remain an incomplete chunk until the caller publishes its
307    /// completion state.
308    #[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
645/// Limits nested casts to selected children: Arrow's List cast otherwise expands
646/// the entire values array, even when the parent has been sliced.
647pub(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                    // Select dictionary entries before recursively converting nested values.
666                    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
736// Count only selected logical values before dictionary expansion, including nested
737// histogram lists/structs. Offset and validity overhead is charged per value.
738fn 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                    // JSON escaping and number formatting only expand JSON values.
808                    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;