1use 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#[derive(Clone, Copy, Debug)]
60pub struct LogicalTableExportLimits {
61 pub input_batch_bytes: usize,
63 pub conversion_bytes: usize,
65 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
95pub 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 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#[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 #[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 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
579fn 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;