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
22use std::collections::{BTreeMap, BTreeSet, HashMap};
23use std::sync::Arc;
24
25use arrow::array::{Array, AsArray, UInt32Array};
26use arrow::compute::cast;
27use arrow::datatypes::{DataType, SchemaRef};
28use arrow::downcast_dictionary_array;
29use arrow::record_batch::RecordBatch;
30use common_datasource::object_store::build_backend_for_write;
31use common_datasource::parquet_writer::{ParquetFileWriter, ParquetWriterLimits};
32use common_meta::key::table_route::{TableRouteManager, TableRouteValue};
33use common_query::OutputData;
34use common_recordbatch::SendableRecordBatchStream;
35use common_time::range::TimestampRange;
36use datafusion::datasource::DefaultTableSource;
37use datafusion_common::TableReference as DfTableReference;
38use datafusion_expr::{LogicalPlan, LogicalPlanBuilder, col};
39use futures::StreamExt;
40use object_store::ObjectStore;
41use session::context::QueryContextRef;
42use snafu::{IntoError, OptionExt, ResultExt, ensure};
43use store_api::metric_engine_consts::{
44    DATA_SCHEMA_TABLE_ID_COLUMN_NAME as TABLE_ID, DATA_SCHEMA_TSID_COLUMN_NAME as TSID,
45    LOGICAL_TABLE_METADATA_KEY, METRIC_ENGINE_NAME, PHYSICAL_TABLE_METADATA_KEY,
46};
47use table::TableRef;
48use table::metadata::TableId;
49use table::table::adapter::DfTableProviderAdapter;
50use tokio_util::sync::CancellationToken;
51
52use crate::error::{self, InvalidLogicalTableExportSnafu, LogicalTableExportResourceSnafu, Result};
53use crate::statement::StatementExecutor;
54use crate::statement::database_copy::DatabaseExportFile;
55
56/// Export preprocessing and per-file limits. Query memory and spill remain
57/// governed by the query engine.
58/// Batch checks happen after allocation; flush thresholds are not hard RSS caps.
59#[derive(Clone, Copy, Debug)]
60pub struct LogicalTableExportLimits {
61    /// Maximum retained scan batch before routing.
62    pub input_batch_bytes: usize,
63    /// Maximum estimated expanded logical values in one conversion slice.
64    pub conversion_bytes: usize,
65    /// Limits owned by the single-file Parquet writer.
66    pub writer: ParquetWriterLimits,
67}
68
69impl Default for LogicalTableExportLimits {
70    fn default() -> Self {
71        Self {
72            input_batch_bytes: 64 * 1024 * 1024,
73            conversion_bytes: 1024 * 1024,
74            writer: ParquetWriterLimits {
75                row_group_rows: 8192,
76                flush_threshold_bytes: 8 * 1024 * 1024,
77                max_row_groups: 4096,
78            },
79        }
80    }
81}
82
83impl LogicalTableExportLimits {
84    fn validate(self) -> Result<()> {
85        ensure!(
86            self.input_batch_bytes > 0 && self.conversion_bytes > 0,
87            InvalidLogicalTableExportSnafu {
88                reason: "export limits must be positive"
89            }
90        );
91        Ok(())
92    }
93}
94
95/// Metadata captured for one physical table and its selected logical tables.
96/// Construct one unit per physical table, schema and time chunk. Capturing these
97/// references supplies no snapshot isolation or locking guarantee.
98pub struct LogicalTableExport {
99    physical_table: TableRef,
100    scan_projection: Vec<usize>,
101    logical_tables: BTreeMap<TableId, LogicalTableProjection>,
102}
103
104struct LogicalTableProjection {
105    output: DatabaseExportFile,
106    schema: SchemaRef,
107    projection: Vec<usize>,
108}
109
110impl LogicalTableExport {
111    /// Capture schemas from selected Metric table references.
112    /// Export validates their physical-table association against table routes.
113    pub fn try_new(physical: TableRef, tables: &[TableRef]) -> Result<Self> {
114        Self::try_new_in_directory(physical, tables, "")
115    }
116
117    pub(crate) fn try_new_in_directory(
118        physical: TableRef,
119        tables: &[TableRef],
120        directory: &str,
121    ) -> Result<Self> {
122        let physical_info = physical.table_info();
123        ensure!(
124            physical_info.meta.engine == METRIC_ENGINE_NAME
125                && physical_info
126                    .meta
127                    .options
128                    .extra_options
129                    .contains_key(PHYSICAL_TABLE_METADATA_KEY)
130                && !tables.is_empty(),
131            InvalidLogicalTableExportSnafu {
132                reason: "expected a Metric physical table and selected logical tables"
133            }
134        );
135        let physical_schema = physical.schema();
136        let id_index = physical_schema.column_index_by_name(TABLE_ID).context(
137            InvalidLogicalTableExportSnafu {
138                reason: "physical schema has no __table_id",
139            },
140        )?;
141        let mut scan_projection = BTreeSet::from([id_index]);
142        let mut logical_tables = BTreeMap::new();
143        for table in tables {
144            let info = table.table_info();
145            let name = &info.name;
146            ensure!(
147                info.catalog_name == physical_info.catalog_name
148                    && info.schema_name == physical_info.schema_name
149                    && info.meta.engine == METRIC_ENGINE_NAME
150                    && info
151                        .meta
152                        .options
153                        .extra_options
154                        .contains_key(LOGICAL_TABLE_METADATA_KEY),
155                InvalidLogicalTableExportSnafu {
156                    reason: format!(
157                        "{name} is not a Metric logical table in the physical table schema"
158                    )
159                }
160            );
161            let schema = table.schema().arrow_schema().clone();
162            let indices = schema
163                .fields()
164                .iter()
165                .map(|field| {
166                    ensure!(
167                        field.name() != TABLE_ID && field.name() != TSID,
168                        InvalidLogicalTableExportSnafu {
169                            reason: "logical schema contains internal Metric columns"
170                        }
171                    );
172                    let index = physical_schema
173                        .column_index_by_name(field.name())
174                        .with_context(|| InvalidLogicalTableExportSnafu {
175                            reason: format!("physical schema lacks {}", field.name()),
176                        })?;
177                    ensure!(
178                        physical_schema.arrow_schema().field(index).data_type()
179                            == field.data_type(),
180                        InvalidLogicalTableExportSnafu {
181                            reason: format!("physical/logical type mismatch for {}", field.name())
182                        }
183                    );
184                    Ok(index)
185                })
186                .collect::<Result<Vec<_>>>()?;
187            scan_projection.extend(indices.iter().copied());
188            ensure!(
189                logical_tables
190                    .insert(
191                        info.table_id(),
192                        LogicalTableProjection {
193                            output: DatabaseExportFile::new(directory, name, ".parquet")?,
194                            schema,
195                            projection: indices,
196                        }
197                    )
198                    .is_none(),
199                InvalidLogicalTableExportSnafu {
200                    reason: "duplicate logical table"
201                }
202            );
203        }
204        let scan_projection = scan_projection.into_iter().collect::<Vec<_>>();
205        for file in logical_tables.values_mut() {
206            for index in &mut file.projection {
207                *index = scan_projection.binary_search(index).map_err(|_| {
208                    error::UnexpectedSnafu {
209                        violated: "logical column missing from physical projection",
210                    }
211                    .build()
212                })?;
213            }
214        }
215        Ok(Self {
216            physical_table: physical,
217            scan_projection,
218            logical_tables,
219        })
220    }
221
222    pub(crate) fn output_files(&self) -> impl Iterator<Item = &DatabaseExportFile> {
223        self.logical_tables.values().map(|table| &table.output)
224    }
225
226    async fn validate_table_routes(&self, manager: &TableRouteManager) -> Result<()> {
227        let table_ids = self.logical_tables.keys().copied().collect::<Vec<_>>();
228        let routes = manager
229            .table_route_storage()
230            .batch_get(&table_ids)
231            .await
232            .context(error::TableMetadataManagerSnafu)?;
233        let physical_table_id = self.physical_table.table_info().table_id();
234        for (table_id, route) in table_ids.into_iter().zip(routes) {
235            ensure!(
236                matches!(route, Some(TableRouteValue::Logical(route)) if route.physical_table_id() == physical_table_id),
237                InvalidLogicalTableExportSnafu {
238                    reason: format!(
239                        "logical table {table_id} does not belong to physical table {physical_table_id}"
240                    )
241                }
242            );
243        }
244        Ok(())
245    }
246
247    fn build_plan(&self, time_range: Option<&TimestampRange>) -> Result<LogicalPlan> {
248        let info = self.physical_table.table_info();
249        let filters = self
250            .physical_table
251            .schema()
252            .timestamp_column()
253            .and_then(|column| {
254                common_query::logical_plan::build_filter_from_timestamp(&column.name, time_range)
255            })
256            .into_iter()
257            .collect::<Vec<_>>();
258        let source = Arc::new(DefaultTableSource::new(Arc::new(
259            DfTableProviderAdapter::new(self.physical_table.clone()),
260        )));
261        let mut builder = LogicalPlanBuilder::scan_with_filters(
262            DfTableReference::full(
263                info.catalog_name.clone(),
264                info.schema_name.clone(),
265                info.name.clone(),
266            ),
267            source,
268            Some(self.scan_projection.clone()),
269            filters.clone(),
270        )
271        .context(error::BuildDfLogicalPlanSnafu)?;
272        for filter in filters {
273            builder = builder
274                .filter(filter)
275                .context(error::BuildDfLogicalPlanSnafu)?;
276        }
277        builder
278            .sort(vec![col(TABLE_ID).sort(true, false)])
279            .context(error::BuildDfLogicalPlanSnafu)?
280            .build()
281            .context(error::BuildDfLogicalPlanSnafu)
282    }
283}
284
285/// Counts returned only after every selected logical file has been closed.
286#[derive(Debug, Default, PartialEq, Eq)]
287pub struct LogicalTableExportSummary {
288    pub rows: usize,
289    pub skipped_rows: usize,
290    pub files: usize,
291}
292
293impl StatementExecutor {
294    /// Export selected logical tables to a fresh directory using one physical query.
295    /// The caller bounds concurrent units and owns retry/cleanup of this directory.
296    /// Cancellation waits for in-flight I/O before aborting the active upload.
297    /// Closed files remain an incomplete chunk until the caller publishes its
298    /// completion state.
299    #[allow(clippy::too_many_arguments)]
300    pub async fn export_logical_tables(
301        &self,
302        unit: &LogicalTableExport,
303        directory: &str,
304        connection: &HashMap<String, String>,
305        time_range: Option<&TimestampRange>,
306        limits: LogicalTableExportLimits,
307        cancellation: &CancellationToken,
308        query_ctx: QueryContextRef,
309    ) -> Result<LogicalTableExportSummary> {
310        limits.validate()?;
311        let (store, stream) = tokio::select! {
312            biased;
313            _ = cancellation.cancelled() => return error::LogicalTableExportCancelledSnafu.fail(),
314            result = async {
315                unit.validate_table_routes(self.table_metadata_manager.table_route_manager()).await?;
316                let store = build_backend_for_write(&format!("{}/", directory.trim_end_matches('/')), connection, &self.local_file_access)
317                    .await.context(error::BuildBackendSnafu)?;
318                let output = self.query_engine.execute(unit.build_plan(time_range)?, query_ctx)
319                    .await.context(error::ExecLogicalPlanSnafu)?;
320                let stream = match output.data {
321                    OutputData::Stream(stream) => stream,
322                    OutputData::RecordBatches(batches) => batches.as_stream(),
323                    _ => return error::UnexpectedSnafu { violated: "expected physical query rows" }.fail(),
324                };
325                Ok((store, stream))
326            } => result?,
327        };
328        export_stream(unit, stream, &store, limits, cancellation).await
329    }
330}
331
332async fn export_stream(
333    unit: &LogicalTableExport,
334    stream: SendableRecordBatchStream,
335    store: &ObjectStore,
336    limits: LogicalTableExportLimits,
337    cancellation: &CancellationToken,
338) -> Result<LogicalTableExportSummary> {
339    let mut active = None;
340    let result = write_tables(unit, stream, store, limits, cancellation, &mut active).await;
341    if result.is_err()
342        && let Some(writer) = active
343        && let Err(cleanup_error) = writer
344            .writer
345            .abort()
346            .await
347            .map_err(|error| map_writer_error(error, &writer.path))
348    {
349        common_telemetry::warn!(cleanup_error; "Failed to clean up incomplete Metric export file");
350    }
351    result
352}
353
354fn check_cancelled(cancellation: &CancellationToken) -> Result<()> {
355    ensure!(
356        !cancellation.is_cancelled(),
357        error::LogicalTableExportCancelledSnafu
358    );
359    Ok(())
360}
361
362async fn write_tables(
363    unit: &LogicalTableExport,
364    mut stream: SendableRecordBatchStream,
365    store: &ObjectStore,
366    limits: LogicalTableExportLimits,
367    cancellation: &CancellationToken,
368    active: &mut Option<ActiveWriter>,
369) -> Result<LogicalTableExportSummary> {
370    let id_index =
371        stream
372            .schema()
373            .column_index_by_name(TABLE_ID)
374            .context(error::UnexpectedSnafu {
375                violated: "physical query omitted __table_id",
376            })?;
377    let mut summary = LogicalTableExportSummary::default();
378    let mut previous = None;
379    let mut written = BTreeSet::new();
380    loop {
381        let batch = tokio::select! {
382            biased;
383            _ = cancellation.cancelled() => return error::LogicalTableExportCancelledSnafu.fail(),
384            batch = stream.next() => batch,
385        };
386        let Some(batch) = batch else {
387            break;
388        };
389        let batch = batch
390            .context(error::BuildRecordBatchSnafu)?
391            .into_df_record_batch();
392        ensure!(
393            batch.get_array_memory_size() <= limits.input_batch_bytes,
394            LogicalTableExportResourceSnafu {
395                reason: "scan batch exceeds input byte budget"
396            }
397        );
398        let ids = batch
399            .column(id_index)
400            .as_any()
401            .downcast_ref::<UInt32Array>()
402            .context(error::UnexpectedSnafu {
403                violated: "__table_id must be UInt32",
404            })?;
405        ensure!(
406            ids.null_count() == 0,
407            error::UnexpectedSnafu {
408                violated: "null __table_id"
409            }
410        );
411        let mut start = 0;
412        while start < batch.num_rows() {
413            check_cancelled(cancellation)?;
414            let id = ids.value(start);
415            ensure!(
416                previous.is_none_or(|last| last <= id),
417                error::UnexpectedSnafu {
418                    violated: "physical query is not ordered by __table_id"
419                }
420            );
421            previous = Some(id);
422            let mut end = start + 1;
423            while end < batch.num_rows() && ids.value(end) == id {
424                end += 1;
425            }
426            if active.as_ref().is_some_and(|writer| writer.table_id != id) {
427                finish_active(active, cancellation).await?;
428            }
429            check_cancelled(cancellation)?;
430            if let Some(file) = unit.logical_tables.get(&id) {
431                if active.is_none() {
432                    *active = Some(ActiveWriter::open(id, file, store, limits).await?);
433                    written.insert(id);
434                    summary.files += 1;
435                }
436                let projected = batch
437                    .project(&file.projection)
438                    .context(error::ProjectSchemaSnafu)?;
439                let writer = active.as_mut().context(error::UnexpectedSnafu {
440                    violated: "missing logical writer",
441                })?;
442                let mut offset = start;
443                while offset < end {
444                    let (expanded, consumed) = expand_bounded_slice(
445                        projected.clone(),
446                        file.schema.clone(),
447                        offset,
448                        end,
449                        limits.conversion_bytes,
450                    )
451                    .await?;
452                    check_cancelled(cancellation)?;
453                    // Do not drop an in-flight file operation before cleanup.
454                    writer
455                        .writer
456                        .write(expanded, Some(cancellation))
457                        .await
458                        .map_err(|error| map_writer_error(error, &writer.path))?;
459                    check_cancelled(cancellation)?;
460                    offset += consumed;
461                    summary.rows += consumed;
462                }
463            } else {
464                summary.skipped_rows += end - start;
465            }
466            start = end;
467        }
468    }
469    finish_active(active, cancellation).await?;
470    for (&id, file) in &unit.logical_tables {
471        if !written.contains(&id) {
472            check_cancelled(cancellation)?;
473            *active = Some(ActiveWriter::open(id, file, store, limits).await?);
474            finish_active(active, cancellation).await?;
475            summary.files += 1;
476        }
477    }
478    check_cancelled(cancellation)?;
479    Ok(summary)
480}
481
482struct ActiveWriter {
483    table_id: u32,
484    path: String,
485    writer: ParquetFileWriter,
486}
487
488impl ActiveWriter {
489    async fn open(
490        id: u32,
491        table: &LogicalTableProjection,
492        store: &ObjectStore,
493        limits: LogicalTableExportLimits,
494    ) -> Result<Self> {
495        let path = table.output.path.clone();
496        ensure!(
497            !store
498                .exists(&path)
499                .await
500                .context(error::ReadObjectSnafu { path: &path })?,
501            InvalidLogicalTableExportSnafu {
502                reason: format!("output already exists: {path}")
503            }
504        );
505        let writer = ParquetFileWriter::open(
506            table.schema.clone(),
507            store.clone(),
508            &path,
509            1,
510            Some(limits.writer),
511        )
512        .await
513        .map_err(|error| map_writer_error(error, &path))?;
514        Ok(Self {
515            table_id: id,
516            path,
517            writer,
518        })
519    }
520}
521
522async fn finish_active(
523    active: &mut Option<ActiveWriter>,
524    cancellation: &CancellationToken,
525) -> Result<()> {
526    if let Some(writer) = active.as_mut() {
527        writer
528            .writer
529            .finish(Some(cancellation))
530            .await
531            .map_err(|error| map_writer_error(error, &writer.path))?;
532        check_cancelled(cancellation)?;
533        *active = None;
534    }
535    Ok(())
536}
537
538async fn expand_bounded_slice(
539    batch: RecordBatch,
540    schema: SchemaRef,
541    start: usize,
542    end: usize,
543    budget: usize,
544) -> Result<(RecordBatch, usize)> {
545    common_runtime::spawn_blocking_global(move || {
546        let len = rows_within_budget(&batch, start, end, budget)?;
547        let slice = batch.slice(start, len);
548        let arrays = slice
549            .columns()
550            .iter()
551            .zip(schema.fields())
552            .map(|(array, field)| cast(array, field.data_type()).context(error::ComputeArrowSnafu))
553            .collect::<Result<Vec<_>>>()?;
554        let expanded = RecordBatch::try_new(schema, arrays).context(error::ComputeArrowSnafu)?;
555        Ok((expanded, len))
556    })
557    .await
558    .context(error::JoinTaskSnafu)?
559}
560
561fn map_writer_error(source: common_datasource::error::Error, path: &str) -> error::Error {
562    match source {
563        common_datasource::error::Error::ParquetWriteCancelled {} => {
564            error::LogicalTableExportCancelledSnafu.build()
565        }
566        common_datasource::error::Error::InvalidParquetWriterLimits {} => {
567            InvalidLogicalTableExportSnafu {
568                reason: "Parquet writer limits must be positive",
569            }
570            .build()
571        }
572        common_datasource::error::Error::ParquetWriterResource { reason } => {
573            LogicalTableExportResourceSnafu { reason }.build()
574        }
575        source => error::WriteStreamToFileSnafu { path }.into_error(source),
576    }
577}
578
579// Count only selected logical values before dictionary expansion, including nested
580// histogram lists/structs. Offset and validity overhead is charged per value.
581fn estimate_value_size(array: &dyn Array, row: usize) -> Result<usize> {
582    if array.is_null(row) {
583        return Ok(32);
584    }
585    let bytes = match array.data_type() {
586        DataType::Boolean => 1,
587        DataType::Null => 0,
588        DataType::Utf8 => array.as_string::<i32>().value(row).len(),
589        DataType::LargeUtf8 => array.as_string::<i64>().value(row).len(),
590        DataType::Binary => array.as_binary::<i32>().value(row).len(),
591        DataType::LargeBinary => array.as_binary::<i64>().value(row).len(),
592        DataType::Struct(_) => {
593            array
594                .as_struct()
595                .columns()
596                .iter()
597                .try_fold(0usize, |sum, child| {
598                    Ok::<_, error::Error>(
599                        sum.saturating_add(estimate_value_size(child.as_ref(), row)?),
600                    )
601                })?
602        }
603        DataType::List(_) => {
604            let list = array.as_list::<i32>();
605            let offsets = list.value_offsets();
606            (offsets[row] as usize..offsets[row + 1] as usize).try_fold(0usize, |sum, index| {
607                Ok::<_, error::Error>(
608                    sum.saturating_add(estimate_value_size(list.values().as_ref(), index)?),
609                )
610            })?
611        }
612        DataType::Dictionary(_, _) => {
613            downcast_dictionary_array! {
614                array => {
615                    match array.key(row) {
616                        Some(index) => estimate_value_size(array.values().as_ref(), index)?,
617                        None => 0,
618                    }
619                },
620                _ => return error::UnexpectedSnafu { violated: "invalid dictionary array" }.fail(),
621            }
622        }
623        other => other
624            .primitive_width()
625            .with_context(|| InvalidLogicalTableExportSnafu {
626                reason: format!("unsupported Metric Parquet type: {other}"),
627            })?,
628    };
629    Ok(bytes.saturating_add(16))
630}
631
632fn rows_within_budget(
633    batch: &RecordBatch,
634    start: usize,
635    end: usize,
636    budget: usize,
637) -> Result<usize> {
638    let mut bytes = 0usize;
639    let mut row = start;
640    while row < end {
641        let row_bytes = batch.columns().iter().try_fold(0usize, |sum, array| {
642            Ok::<_, error::Error>(sum.saturating_add(estimate_value_size(array.as_ref(), row)?))
643        })?;
644        if row_bytes > budget.saturating_sub(bytes) {
645            break;
646        }
647        bytes += row_bytes;
648        row += 1;
649    }
650    ensure!(
651        row > start,
652        LogicalTableExportResourceSnafu {
653            reason: "one expanded logical row exceeds conversion byte budget"
654        }
655    );
656    Ok(row - start)
657}
658
659#[cfg(test)]
660mod tests;