This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 30d20c3b feat(datafusion): support ANALYZE TABLE on catalog-managed
format tables (#815)
30d20c3b is described below
commit 30d20c3b4e791d5de299ab139b62b56932f28059
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Mon Sep 14 09:06:28 2026 +0800
feat(datafusion): support ANALYZE TABLE on catalog-managed format tables
(#815)
---
.../datafusion/src/format_partition_analyze.rs | 196 ++++++++++++
.../datafusion/src/format_partition_ddl.rs | 8 +-
crates/integrations/datafusion/src/lib.rs | 1 +
crates/integrations/datafusion/src/sql_context.rs | 9 +-
.../datafusion/tests/rest_format_partition_sql.rs | 338 +++++++++++++++++++++
crates/paimon/src/arrow/format/mod.rs | 27 +-
crates/paimon/src/arrow/format/orc.rs | 23 ++
crates/paimon/src/arrow/format/parquet.rs | 17 ++
crates/paimon/src/table/format_partition.rs | 2 +-
crates/paimon/src/table/format_partition_stats.rs | 186 ++++++++++++
crates/paimon/src/table/format_table_scan.rs | 202 ++++++++----
crates/paimon/src/table/mod.rs | 2 +
crates/paimon/tests/mock_server.rs | 11 +
docs/src/sql.md | 37 ++-
14 files changed, 998 insertions(+), 61 deletions(-)
diff --git a/crates/integrations/datafusion/src/format_partition_analyze.rs
b/crates/integrations/datafusion/src/format_partition_analyze.rs
new file mode 100644
index 00000000..71a8c610
--- /dev/null
+++ b/crates/integrations/datafusion/src/format_partition_analyze.rs
@@ -0,0 +1,196 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! ANALYZE TABLE for Format Tables with catalog-managed partitions.
+
+use std::collections::HashSet;
+
+use datafusion::error::{DataFusionError, Result as DFResult};
+use datafusion::prelude::DataFrame;
+use datafusion::sql::sqlparser::ast::{Analyze, Expr as SqlExpr};
+use paimon::table::FormatTablePartitionStatsCollector;
+
+use crate::error::to_datafusion_error;
+use crate::format_partition_ddl::{
+ ensure_catalog_managed_format_table, has_custom_location,
parse_format_partition_spec,
+};
+use crate::sql_context::{
+ normalize_schema_identifier, ok_result, partition_assignment, SQLContext,
+};
+
+/// `ANALYZE TABLE t [PARTITION (...)] COMPUTE STATISTICS [NOSCAN]` on a
Format Table with
+/// catalog-managed partitions. Mirrors Java
`PaimonAnalyzeFormatTablePartitionsCommand`.
+pub(crate) async fn execute_analyze(
+ ctx: &SQLContext,
+ analyze: &Analyze,
+ enable_ident_normalization: bool,
+) -> DFResult<DataFrame> {
+ let Some(table_name) = &analyze.table_name else {
+ return Err(DataFusionError::Plan(
+ "ANALYZE requires a table name".to_string(),
+ ));
+ };
+ if analyze.for_columns || !analyze.columns.is_empty() {
+ return Err(DataFusionError::NotImplemented(
+ "ANALYZE TABLE ... FOR COLUMNS is not supported: a Format Table
has nowhere to \
+ keep column statistics"
+ .to_string(),
+ ));
+ }
+ if analyze.cache_metadata {
+ return Err(DataFusionError::NotImplemented(
+ "ANALYZE TABLE ... CACHE METADATA is not supported".to_string(),
+ ));
+ }
+ if !analyze.compute_statistics {
+ return Err(DataFusionError::Plan(
+ "ANALYZE TABLE requires COMPUTE STATISTICS".to_string(),
+ ));
+ }
+ SQLContext::ensure_partition_command_target(table_name, "ANALYZE TABLE")?;
+ let (catalog, _catalog_name, identifier) =
ctx.resolve_catalog_and_table(table_name)?;
+ let table = catalog
+ .get_table(&identifier)
+ .await
+ .map_err(to_datafusion_error)?;
+ ensure_catalog_managed_format_table(&table, "ANALYZE TABLE")?;
+ let prefix = analyze_partition_prefix(
+ analyze.partitions.as_deref().unwrap_or_default(),
+ &table,
+ enable_ident_normalization,
+ )?;
+
+ let selected = catalog
+ .list_partitions(&identifier)
+ .await
+ .map_err(to_datafusion_error)?
+ .into_iter()
+ .filter(|partition| {
+ prefix
+ .iter()
+ .all(|(key, value)| partition.spec.get(key) == Some(value))
+ })
+ .collect::<Vec<_>>();
+ if selected.is_empty() && !prefix.is_empty() {
+ return Err(DataFusionError::Plan(format!(
+ "Partition {prefix:?} does not exist in table {}",
+ identifier.full_name()
+ )));
+ }
+ let custom_located = selected
+ .iter()
+ .filter(|partition| has_custom_location(partition))
+ .map(|partition| &partition.spec)
+ .collect::<Vec<_>>();
+ if !custom_located.is_empty() {
+ return Err(DataFusionError::NotImplemented(format!(
+ "ANALYZE TABLE cannot measure partitions with a custom location in
Format Table \
+ {}: {custom_located:?}",
+ identifier.full_name()
+ )));
+ }
+ if selected.is_empty() {
+ return ok_result(ctx.ctx());
+ }
+
+ let specs = selected
+ .into_iter()
+ .map(|partition| partition.spec)
+ .collect::<Vec<_>>();
+ let statistics = FormatTablePartitionStatsCollector::new(
+ &table,
+ !analyze.noscan,
+ format_table_statistics_parallelism(ctx),
+ )
+ .collect(&specs)
+ .await
+ .map_err(to_datafusion_error)?;
+ catalog
+ .create_partitions_with_statistics(&identifier, specs, true,
Some(statistics), true)
+ .await
+ .map_err(to_datafusion_error)?;
+ ok_result(ctx.ctx())
+}
+
+/// `format-table.statistics.parallelism` from the session (`SET
'paimon.<key>'`), default 8.
+/// A value below one is read as one.
+fn format_table_statistics_parallelism(ctx: &SQLContext) -> usize {
+ const KEY: &str = "format-table.statistics.parallelism";
+ ctx.dynamic_options()
+ .read()
+ .unwrap()
+ .get(KEY)
+ .and_then(|value| value.trim().parse::<i64>().ok())
+ .map(|value| value.max(1) as usize)
+ .unwrap_or(8)
+}
+
+/// The values an `ANALYZE ... PARTITION (...)` clause fixes, in partition-key
order; valued
+/// columns must be a leading run of the keys, so `PARTITION (hour = '00')` is
rejected.
+fn analyze_partition_prefix(
+ expressions: &[SqlExpr],
+ table: &paimon::Table,
+ enable_ident_normalization: bool,
+) -> DFResult<Vec<(String, String)>> {
+ let partition_keys = table.schema().partition_keys();
+ let mut named = HashSet::with_capacity(expressions.len());
+ let mut assignments = Vec::with_capacity(expressions.len());
+ for expression in expressions {
+ let column = match expression {
+ SqlExpr::Identifier(identifier) => {
+ normalize_schema_identifier(identifier,
enable_ident_normalization)
+ }
+ other => {
+ let (column, _) = partition_assignment(other,
enable_ident_normalization)?;
+ assignments.push(other.clone());
+ column
+ }
+ };
+ if !partition_keys.contains(&column) {
+ return Err(DataFusionError::Plan(format!(
+ "Column '{column}' is not a partition column"
+ )));
+ }
+ if !named.insert(column.clone()) {
+ return Err(DataFusionError::Plan(format!(
+ "Duplicate partition column '{column}'"
+ )));
+ }
+ }
+ let spec = parse_format_partition_spec(
+ &assignments,
+ table,
+ false,
+ Some("ANALYZE TABLE"),
+ enable_ident_normalization,
+ )?;
+ let leading = partition_keys
+ .iter()
+ .take_while(|key| spec.contains_key(key.as_str()))
+ .count();
+ if leading != spec.len() {
+ return Err(DataFusionError::Plan(format!(
+ "ANALYZE TABLE {} PARTITION must give values for a leading run of
its partition \
+ columns {partition_keys:?}",
+ table.identifier().full_name()
+ )));
+ }
+ Ok(partition_keys[..leading]
+ .iter()
+ .map(|key| (key.clone(), spec[key].clone()))
+ .collect())
+}
diff --git a/crates/integrations/datafusion/src/format_partition_ddl.rs
b/crates/integrations/datafusion/src/format_partition_ddl.rs
index b611c779..5c08a359 100644
--- a/crates/integrations/datafusion/src/format_partition_ddl.rs
+++ b/crates/integrations/datafusion/src/format_partition_ddl.rs
@@ -360,7 +360,7 @@ pub(crate) async fn drop_catalog_managed_partitions(
/// Whether the catalog registered a partition at a location of its own rather
than under the
/// table directory.
-fn has_custom_location(partition: &paimon::spec::Partition) -> bool {
+pub(crate) fn has_custom_location(partition: &paimon::spec::Partition) -> bool
{
partition
.options
.as_ref()
@@ -386,9 +386,9 @@ pub(crate) fn ensure_catalog_managed_format_table(
Ok(())
}
-/// `mutating_operation` names the statement when it changes partitions (ADD
or DROP PARTITION),
-/// which refuses a blank string for a string partition column.
-fn parse_format_partition_spec(
+/// `mutating_operation` names the statement when it changes partitions or
their statistics (ADD
+/// or DROP PARTITION, ANALYZE TABLE), which refuses a blank string for a
string partition column.
+pub(crate) fn parse_format_partition_spec(
exprs: &[SqlExpr],
table: &paimon::Table,
require_complete: bool,
diff --git a/crates/integrations/datafusion/src/lib.rs
b/crates/integrations/datafusion/src/lib.rs
index dca1d409..ad1e8c32 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -43,6 +43,7 @@ mod catalog;
mod delete;
mod error;
mod filter_pushdown;
+mod format_partition_analyze;
mod format_partition_ddl;
mod format_partition_repair;
#[cfg(feature = "fulltext")]
diff --git a/crates/integrations/datafusion/src/sql_context.rs
b/crates/integrations/datafusion/src/sql_context.rs
index 09cf9764..41a425eb 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -436,7 +436,6 @@ impl SQLContext {
}
}
- #[cfg(test)]
pub(crate) fn dynamic_options(&self) -> &DynamicOptions {
&self.dynamic_options
}
@@ -611,6 +610,14 @@ impl SQLContext {
.await
}
Statement::Msck(msck) =>
crate::format_partition_repair::execute_msck(self, msck).await,
+ Statement::Analyze(analyze) => {
+ crate::format_partition_analyze::execute_analyze(
+ self,
+ analyze,
+ enable_ident_normalization,
+ )
+ .await
+ }
Statement::CreateView(create_view) => {
if create_view.temporary {
// Temporary views are always handled by us (Paimon
catalog temp storage)
diff --git a/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
b/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
index 9a3073f7..e07b1f4b 100644
--- a/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
+++ b/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
@@ -21,13 +21,17 @@ mod common;
mod mock_server;
use std::collections::HashMap;
+use std::path::Path;
use std::sync::Arc;
+use arrow_array::{Int64Array, RecordBatch};
+use arrow_schema::{DataType as ArrowDataType, Field, Schema as ArrowSchema};
use paimon::api::ConfigResponse;
use paimon::catalog::RESTCatalog;
use paimon::spec::{BigIntType, BooleanType, DataType, DateType, IntType,
Schema, VarCharType};
use paimon::{CatalogOptions, Options};
use paimon_datafusion::SQLContext;
+use parquet::arrow::ArrowWriter;
use tempfile::TempDir;
use mock_server::{start_mock_server, RESTServer};
@@ -130,6 +134,66 @@ fn spec(values: &[(&str, &str)]) -> HashMap<String,
String> {
.collect()
}
+const UNKNOWN: i64 = paimon::spec::Partition::UNKNOWN;
+
+/// The partitions the catalog holds, by partition name with keys in name
order.
+fn partition_statistics(server: &RESTServer) -> HashMap<String,
paimon::spec::Partition> {
+ server
+ .table_partitions(DATABASE, TABLE)
+ .into_iter()
+ .map(|partition| {
+ let mut entries = partition.spec.iter().collect::<Vec<_>>();
+ entries.sort();
+ let name = entries
+ .into_iter()
+ .map(|(key, value)| format!("{key}={value}"))
+ .collect::<Vec<_>>()
+ .join("/");
+ (name, partition)
+ })
+ .collect()
+}
+
+fn counts(partition: &paimon::spec::Partition) -> (i64, i64) {
+ (partition.record_count, partition.file_count)
+}
+
+fn write_ids(directory: &Path, ids: &[i64]) {
+ write_ids_file(&directory.join("part-0.parquet"), ids);
+}
+
+fn write_ids_file(path: &Path, ids: &[i64]) {
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ let schema = Arc::new(ArrowSchema::new(vec![Field::new(
+ "id",
+ ArrowDataType::Int64,
+ true,
+ )]));
+ let batch = RecordBatch::try_new(
+ Arc::clone(&schema),
+ vec![Arc::new(Int64Array::from(ids.to_vec()))],
+ )
+ .unwrap();
+ let file = std::fs::File::create(path).unwrap();
+ let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
+ writer.write(&batch).unwrap();
+ writer.close().unwrap();
+}
+
+async fn ids(context: &SQLContext, sql: &str) -> Vec<i64> {
+ let mut ids = Vec::new();
+ for batch in context.sql(sql).await.unwrap().collect().await.unwrap() {
+ let values = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ ids.extend(values.iter().flatten());
+ }
+ ids.sort_unstable();
+ ids
+}
+
#[cfg(not(windows))]
#[tokio::test]
async fn test_partition_commands_update_rest_metadata_and_directories() {
@@ -593,6 +657,280 @@ async fn
test_msck_repair_keeps_a_partition_at_a_custom_location() {
);
}
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn
test_analyze_measures_registered_partitions_and_replaces_their_statistics() {
+ let (temp_dir, server, context) = dt_hh_table(&[("a", "00"), ("a", "01"),
("b", "00")]).await;
+ write_ids_file(&temp_dir.path().join("dt=a/hh=00/part-0.parquet"), &[1,
2]);
+ write_ids_file(&temp_dir.path().join("dt=a/hh=00/part-1.parquet"), &[3]);
+ write_ids_file(&temp_dir.path().join("dt=a/hh=01/part-0.parquet"), &[4]);
+
+ // NOSCAN measures what a listing gives and leaves the row counts as they
were.
+ common::exec(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} COMPUTE STATISTICS NOSCAN"),
+ )
+ .await;
+ let measured = partition_statistics(&server);
+ assert_eq!(counts(&measured["dt=a/hh=00"]), (UNKNOWN, 2));
+ assert_eq!(counts(&measured["dt=a/hh=01"]), (UNKNOWN, 1));
+ assert_eq!(counts(&measured["dt=b/hh=00"]), (UNKNOWN, 0));
+ assert!(measured["dt=a/hh=00"].file_size_in_bytes > 0);
+ assert!(measured["dt=a/hh=00"].last_file_creation_time > 0);
+ assert_eq!(measured["dt=b/hh=00"].file_size_in_bytes, 0);
+ assert_eq!(measured["dt=b/hh=00"].last_file_creation_time, UNKNOWN);
+
+ // A full ANALYZE reads every footer, and an empty partition holds exactly
no rows.
+ common::exec(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} COMPUTE STATISTICS"),
+ )
+ .await;
+ let measured = partition_statistics(&server);
+ assert_eq!(counts(&measured["dt=a/hh=00"]), (3, 2));
+ assert_eq!(counts(&measured["dt=a/hh=01"]), (1, 1));
+ assert_eq!(counts(&measured["dt=b/hh=00"]), (0, 0));
+
+ // A later NOSCAN keeps the known row counts, and measuring again replaces
rather than adds.
+ common::exec(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} COMPUTE STATISTICS NOSCAN"),
+ )
+ .await;
+ let remeasured = partition_statistics(&server);
+ assert_eq!(counts(&remeasured["dt=a/hh=00"]), (3, 2));
+ assert_eq!(counts(&remeasured["dt=a/hh=01"]), (1, 1));
+ assert_eq!(
+ remeasured["dt=a/hh=00"].file_size_in_bytes,
+ measured["dt=a/hh=00"].file_size_in_bytes
+ );
+
+ let calls = server.create_partitions_calls();
+ let (_, _, request) = calls.last().unwrap();
+ assert!(request.ignore_if_exists);
+ assert_eq!(request.replace_statistics, Some(true));
+ assert_eq!(server.table_partition_specs(DATABASE, TABLE).len(), 3);
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn
test_analyze_partition_clause_selects_a_leading_run_of_partition_values() {
+ let (temp_dir, server, context) = dt_hh_table(&[("a", "00"), ("a", "01"),
("b", "00")]).await;
+ for directory in ["dt=a/hh=00", "dt=a/hh=01", "dt=b/hh=00"] {
+ write_ids(&temp_dir.path().join(directory), &[1]);
+ }
+ let file_counts = |server: &RESTServer| {
+ let measured = partition_statistics(server);
+ ["dt=a/hh=00", "dt=a/hh=01", "dt=b/hh=00"].map(|name|
measured[name].file_count)
+ };
+
+ common::exec(
+ &context,
+ &format!(
+ "ANALYZE TABLE {TABLE_NAME} PARTITION (dt = 'a', hh = '00')
COMPUTE STATISTICS NOSCAN"
+ ),
+ )
+ .await;
+ assert_eq!(file_counts(&server), [1, UNKNOWN, UNKNOWN]);
+
+ // A column named without a value means every value of it.
+ common::exec(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} PARTITION (dt = 'a', hh) COMPUTE
STATISTICS NOSCAN"),
+ )
+ .await;
+ assert_eq!(file_counts(&server), [1, 1, UNKNOWN]);
+ common::exec(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} PARTITION (dt, hh) COMPUTE
STATISTICS NOSCAN"),
+ )
+ .await;
+ assert_eq!(file_counts(&server), [1, 1, 1]);
+
+ for (clause, message) in [
+ ("PARTITION (hh = '00')", "leading run"),
+ ("PARTITION (id = 1)", "not a partition column"),
+ ("PARTITION (dt = 'zzz')", "does not exist"),
+ ] {
+ common::assert_sql_error(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} {clause} COMPUTE STATISTICS
NOSCAN"),
+ message,
+ )
+ .await;
+ }
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_analyze_reads_a_partition_value_as_its_column_type() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let schema = format_table_schema(&[("p", DataType::Int(IntType::new()))]);
+ let (server, context) = setup_rest_table(&temp_dir, schema).await;
+ common::exec(
+ &context,
+ &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION (p = 1)"),
+ )
+ .await;
+ write_ids(&temp_dir.path().join("p=1"), &[1]);
+
+ common::exec(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} PARTITION (p = '01') COMPUTE
STATISTICS NOSCAN"),
+ )
+ .await;
+
+ assert_eq!(partition_statistics(&server)["p=1"].file_count, 1);
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_analyze_and_scan_count_only_the_files_a_reader_returns() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let (server, context) =
+ setup_rest_table(&temp_dir, format_table_schema(&[("dt",
varchar())])).await;
+ common::exec(
+ &context,
+ &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION (dt = 'a')"),
+ )
+ .await;
+ let partition = temp_dir.path().join("dt=a");
+ write_ids_file(&partition.join("part-0.parquet"), &[1]);
+ // What committers and tools leave beside the data: staging trees,
markers, hidden files.
+ write_ids_file(&partition.join("_temporary/0/part-9.parquet"), &[9]);
+ write_ids_file(&partition.join("__magic_job-1/tasks/part-8.parquet"),
&[8]);
+ write_ids_file(&partition.join(".part-7.parquet"), &[7]);
+ std::fs::write(partition.join("_SUCCESS"), b"").unwrap();
+ std::fs::write(partition.join("notes.txt"), b"not data").unwrap();
+
+ common::exec(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} COMPUTE STATISTICS"),
+ )
+ .await;
+
+ assert_eq!(counts(&partition_statistics(&server)["dt=a"]), (1, 1));
+ assert_eq!(
+ ids(
+ &context,
+ &format!("SELECT id FROM {TABLE_NAME} WHERE dt = 'a'")
+ )
+ .await,
+ vec![1]
+ );
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_analyze_leaves_a_row_count_unknown_rather_than_short() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let (server, context) =
+ setup_rest_table(&temp_dir, format_table_schema(&[("dt",
varchar())])).await;
+ common::exec(
+ &context,
+ &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION (dt = 'a')"),
+ )
+ .await;
+ write_ids_file(&temp_dir.path().join("dt=a/part-0.parquet"), &[1, 2]);
+ std::fs::write(
+ temp_dir.path().join("dt=a/part-1.parquet"),
+ b"not a parquet footer",
+ )
+ .unwrap();
+
+ common::exec(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} COMPUTE STATISTICS"),
+ )
+ .await;
+
+ // A sum missing one file, reported as exact, would be worse than no
number.
+ assert_eq!(counts(&partition_statistics(&server)["dt=a"]), (UNKNOWN, 2));
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_analyze_refuses_what_it_cannot_measure() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let (server, context) =
+ setup_rest_table(&temp_dir, format_table_schema(&[("dt",
varchar())])).await;
+ for dt in ["a", "b"] {
+ common::exec(
+ &context,
+ &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION (dt = '{dt}')"),
+ )
+ .await;
+ write_ids(&temp_dir.path().join(format!("dt={dt}")), &[1]);
+ }
+
+ common::assert_sql_error(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} COMPUTE STATISTICS FOR COLUMNS
id"),
+ "FOR COLUMNS",
+ )
+ .await;
+
+ // A blank string names the default partition, which the statement would
measure instead.
+ common::assert_sql_error(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} PARTITION (dt = '') COMPUTE
STATISTICS"),
+ "empty or whitespace-only string for partition column 'dt'",
+ )
+ .await;
+
+ server.set_table_partition_options(
+ DATABASE,
+ TABLE,
+ &spec(&[("dt", "b")]),
+ HashMap::from([("path".to_string(),
"file:///elsewhere/b".to_string())]),
+ );
+ common::assert_sql_error(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} COMPUTE STATISTICS NOSCAN"),
+ "custom location",
+ )
+ .await;
+ assert!(partition_statistics(&server)
+ .values()
+ .all(|partition| partition.file_count == UNKNOWN));
+
+ // A non-positive parallelism is read as one rather than failing the
statement.
+ common::exec(
+ &context,
+ "SET \"paimon.format-table.statistics.parallelism\" = '0'",
+ )
+ .await;
+ common::exec(
+ &context,
+ &format!("ANALYZE TABLE {TABLE_NAME} PARTITION (dt = 'a') COMPUTE
STATISTICS"),
+ )
+ .await;
+ assert_eq!(counts(&partition_statistics(&server)["dt=a"]), (1, 1));
+
+ // Without catalog-managed partitions there is no catalog to write the
numbers to.
+ let plain = Schema::builder()
+ .column("dt", varchar())
+ .column("id", DataType::BigInt(BigIntType::new()))
+ .partition_keys(["dt"])
+ .option("type", "format-table")
+ .option("file.format", "parquet")
+ .build()
+ .unwrap();
+ server.add_table_with_schema(
+ DATABASE,
+ "plain",
+ plain,
+ &format!("file://{}/plain", temp_dir.path().display()),
+ );
+ server.set_table_external(DATABASE, "plain", false);
+ common::assert_sql_error(
+ &context,
+ "ANALYZE TABLE paimon.default.plain COMPUTE STATISTICS",
+ "catalog-managed",
+ )
+ .await;
+}
+
/// `SQLContext::sql` futures have to stay `Send` for callers that box or
spawn them; this stops
/// compiling when a stream over borrowed items anywhere below a statement
takes that away.
#[allow(dead_code)]
diff --git a/crates/paimon/src/arrow/format/mod.rs
b/crates/paimon/src/arrow/format/mod.rs
index 7a5abbd8..f67ae565 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -30,7 +30,7 @@ pub(crate) use parquet::ParquetFormatWriter;
use super::ParquetReadBudget;
use super::RowFilterFactory;
-use crate::io::{FileRead, OutputFile};
+use crate::io::{FileIO, FileRead, OutputFile};
use crate::spec::stats::BinaryTableStats;
use crate::spec::{DataField, Predicate};
use crate::table::{ArrowRecordBatchStream, RowRange};
@@ -158,6 +158,31 @@ impl FormatWriteResult {
}
}
+/// Rows in a data file of the given format, read from its footer alone, or
`None` when the
+/// format keeps no row count there and every row would have to be decoded to
count them.
+pub(crate) async fn read_file_row_count(
+ file_io: &FileIO,
+ format: &str,
+ path: &str,
+ file_size: u64,
+) -> crate::Result<Option<i64>> {
+ match format.to_ascii_lowercase().as_str() {
+ "parquet" => {
+ let reader = file_io.new_input(path)?.reader().await?;
+ parquet::read_row_count(Box::new(reader), file_size)
+ .await
+ .map(Some)
+ }
+ "orc" => {
+ let reader = file_io.new_input(path)?.reader().await?;
+ orc::read_row_count(Box::new(reader), file_size)
+ .await
+ .map(Some)
+ }
+ _ => Ok(None),
+ }
+}
+
/// Create a format reader based on the file extension.
#[cfg(test)]
pub(crate) fn create_format_reader(
diff --git a/crates/paimon/src/arrow/format/orc.rs
b/crates/paimon/src/arrow/format/orc.rs
index fd2e3811..116b62aa 100644
--- a/crates/paimon/src/arrow/format/orc.rs
+++ b/crates/paimon/src/arrow/format/orc.rs
@@ -368,6 +368,29 @@ fn build_range_row_selection(
)
}
+/// Rows in an ORC file, read from its footer alone.
+pub(crate) async fn read_row_count(
+ reader: Box<dyn FileRead>,
+ file_size: u64,
+) -> crate::Result<i64> {
+ let builder =
ArrowReaderBuilder::try_new_async(OrcFileReader::new(file_size, reader))
+ .await
+ .map_err(|error| Error::UnexpectedError {
+ message: format!("Failed to open ORC file: {error}"),
+ source: Some(Box::new(error)),
+ })?;
+ let rows = builder
+ .file_metadata()
+ .stripe_metadatas()
+ .iter()
+ .map(|stripe| stripe.number_of_rows())
+ .sum::<u64>();
+ i64::try_from(rows).map_err(|_| Error::DataInvalid {
+ message: format!("ORC file holds {rows} rows, more than a row count
can carry"),
+ source: None,
+ })
+}
+
// ---------------------------------------------------------------------------
// OrcFileReader — adapts paimon FileRead to orc-rust AsyncChunkReader
// ---------------------------------------------------------------------------
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index 7aab8a07..583b7628 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -1964,6 +1964,23 @@ const METADATA_SIZE_HINT: usize = 512 * 1024;
/// avoid excessive small IO requests whose per-request overhead dominates.
const IO_BLOCK_SIZE: u64 = 4 * 1024 * 1024;
+/// Rows in a Parquet file, read from its footer alone.
+pub(crate) async fn read_row_count(
+ reader: Box<dyn FileRead>,
+ file_size: u64,
+) -> crate::Result<i64> {
+ let reader = ArrowFileReader::new(file_size, Arc::from(reader));
+ let metadata = ParquetMetaDataReader::new()
+ .with_prefetch_hint(Some(METADATA_SIZE_HINT))
+ .load_and_finish(reader, file_size)
+ .await
+ .map_err(|error| Error::UnexpectedError {
+ message: format!("Failed to read the Parquet footer: {error}"),
+ source: Some(Box::new(error)),
+ })?;
+ Ok(metadata.file_metadata().num_rows())
+}
+
impl ArrowFileReader {
fn new(file_size: u64, r: Arc<dyn FileRead>) -> Self {
Self { file_size, r }
diff --git a/crates/paimon/src/table/format_partition.rs
b/crates/paimon/src/table/format_partition.rs
index 0ee14316..0f27e9a0 100644
--- a/crates/paimon/src/table/format_partition.rs
+++ b/crates/paimon/src/table/format_partition.rs
@@ -325,7 +325,7 @@ fn format_partition_date(epoch_days: i32) -> Option<String>
{
.map(|date| date.format("%Y-%m-%d").to_string())
}
-fn is_storage_not_found(error: &crate::Error) -> bool {
+pub(crate) fn is_storage_not_found(error: &crate::Error) -> bool {
matches!(
error,
crate::Error::IoUnexpected { source, .. }
diff --git a/crates/paimon/src/table/format_partition_stats.rs
b/crates/paimon/src/table/format_partition_stats.rs
new file mode 100644
index 00000000..6fcc86db
--- /dev/null
+++ b/crates/paimon/src/table/format_partition_stats.rs
@@ -0,0 +1,186 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Measures the partitions of a Format Table with catalog-managed partitions.
+
+use std::collections::HashMap;
+
+use futures::{StreamExt, TryStreamExt};
+
+use super::format_partition::FormatTablePartitionPaths;
+use super::format_table_scan::{list_format_table_data_files,
supported_format_table_extension};
+use super::Table;
+use crate::arrow::format::read_file_row_count;
+use crate::io::FileStatus;
+use crate::spec::{CoreOptions, Partition, PartitionStatistics};
+
+/// Measures whole partitions of a Format Table through the listing a scan
uses.
+/// Mirrors Java `FormatTablePartitionStatsCollector`.
+#[derive(Debug)]
+pub struct FormatTablePartitionStatsCollector<'a> {
+ table: &'a Table,
+ with_record_count: bool,
+ parallelism: usize,
+}
+
+impl<'a> FormatTablePartitionStatsCollector<'a> {
+ /// Measure `table`, reading file footers for row counts only when
`with_record_count` is set.
+ /// `parallelism` bounds listings and footer reads together; a value below
one is read as one.
+ pub fn new(table: &'a Table, with_record_count: bool, parallelism: usize)
-> Self {
+ Self {
+ table,
+ with_record_count,
+ parallelism: parallelism.max(1),
+ }
+ }
+
+ /// Measure the given complete partition specs. The result is aligned with
`partitions` one for
+ /// one, so it can be sent to the catalog together with the same specs.
+ pub async fn collect(
+ &self,
+ partitions: &[HashMap<String, String>],
+ ) -> crate::Result<Vec<PartitionStatistics>> {
+ if partitions.is_empty() {
+ return Ok(Vec::new());
+ }
+ if !self.table.has_catalog_managed_partitions() {
+ return Err(crate::Error::Unsupported {
+ message: format!(
+ "Format Table {} does not have catalog-managed partitions,
so its partitions \
+ cannot be measured",
+ self.table.identifier().full_name()
+ ),
+ });
+ }
+ let options = CoreOptions::new(self.table.schema().options());
+ let format_extension =
supported_format_table_extension(&options.file_format())?;
+ let partition_paths = FormatTablePartitionPaths::new(
+ self.table.schema().partition_keys().iter().cloned(),
+ options.format_table_partition_only_value_in_path(),
+ );
+ let table_path = options
+ .path()
+ .unwrap_or_else(|| self.table.location())
+ .trim_end_matches('/');
+ let directories = partitions
+ .iter()
+ .map(|spec| {
+ partition_paths
+ .relative_path(spec)
+ .map(|relative_path|
format!("{table_path}/{relative_path}"))
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+
+ let file_io = self.table.file_io();
+ // Each future owns what it lists. A stream over borrowed items would
leave the future of
+ // any SQL statement that measures partitions without a provable
`Send`.
+ let listings: Vec<Vec<FileStatus>> = futures::stream::iter(directories)
+ .map(|directory| async move {
+ // Each directory is a complete partition, so no partition
level lies below it.
+ list_format_table_data_files(file_io, &directory, 0,
format_extension).await
+ })
+ .buffered(self.parallelism)
+ .try_collect()
+ .await?;
+
+ let record_counts = if self.with_record_count {
+ self.count_rows(&options.file_format(), &listings).await
+ } else {
+ vec![Partition::UNKNOWN; listings.len()]
+ };
+
+ Ok(partitions
+ .iter()
+ .zip(&listings)
+ .zip(record_counts)
+ .map(|((spec, files), record_count)| statistics(spec, files,
record_count))
+ .collect())
+ }
+
+ /// The rows each listed partition holds. Every file of every partition
goes through one
+ /// bounded stream, so a partition with many files is counted with all of
it.
+ async fn count_rows(&self, file_format: &str, listings:
&[Vec<FileStatus>]) -> Vec<i64> {
+ let file_io = self.table.file_io();
+ let files = listings
+ .iter()
+ .enumerate()
+ .flat_map(|(index, files)| {
+ files
+ .iter()
+ .map(move |file| (index, file.path.clone(), file.size))
+ })
+ .collect::<Vec<_>>();
+ let counts: Vec<(usize, Option<i64>)> = futures::stream::iter(files)
+ .map(|(index, path, size)| async move {
+ let count = match read_file_row_count(file_io, file_format,
&path, size).await {
+ Ok(count) => count,
+ Err(error) => {
+ log::warn!(
+ "Failed to read the row count of {path} in table
{}; the row count \
+ of its partition stays unknown: {error}",
+ self.table.identifier().full_name()
+ );
+ None
+ }
+ };
+ (index, count)
+ })
+ .buffered(self.parallelism)
+ .collect()
+ .await;
+
+ // A partition with no files holds exactly zero rows; one file whose
count is unknown
+ // makes the whole partition unknown rather than short.
+ let mut record_counts = vec![Some(0i64); listings.len()];
+ for (index, count) in counts {
+ record_counts[index] = match (record_counts[index], count) {
+ (Some(total), Some(count)) => total.checked_add(count),
+ _ => None,
+ };
+ }
+ record_counts
+ .into_iter()
+ .map(|count| count.unwrap_or(Partition::UNKNOWN))
+ .collect()
+ }
+}
+
+/// What the listed files of a partition add up to.
+fn statistics(
+ spec: &HashMap<String, String>,
+ files: &[FileStatus],
+ record_count: i64,
+) -> PartitionStatistics {
+ let file_size_in_bytes = files
+ .iter()
+ .map(|file| i64::try_from(file.size).unwrap_or(i64::MAX))
+ .fold(0i64, i64::saturating_add);
+ let last_file_creation_time = files
+ .iter()
+ .filter_map(|file| file.last_modified)
+ .map(|modified| modified.timestamp_millis())
+ .max()
+ .unwrap_or(Partition::UNKNOWN);
+ PartitionStatistics {
+ spec: spec.clone(),
+ record_count,
+ file_size_in_bytes,
+ file_count: files.len() as i64,
+ last_file_creation_time,
+ total_buckets: Partition::UNKNOWN_TOTAL_BUCKETS,
+ }
+}
diff --git a/crates/paimon/src/table/format_table_scan.rs
b/crates/paimon/src/table/format_table_scan.rs
index de2c02e0..b002e88a 100644
--- a/crates/paimon/src/table/format_table_scan.rs
+++ b/crates/paimon/src/table/format_table_scan.rs
@@ -20,7 +20,8 @@
use std::collections::{HashMap, HashSet};
use super::format_partition::{
- format_partition_value, parse_format_partition_value,
FormatTablePartitionPaths,
+ format_partition_value, is_storage_not_found, parse_format_partition_value,
+ FormatTablePartitionPaths,
};
use super::{Plan, RESTEnv, ScanTrace, Table};
use crate::api::RestError;
@@ -107,29 +108,22 @@ impl<'a> FormatTableScan<'a> {
let partition_levels_below_root = partition_fields
.len()
.saturating_sub(root_segments.len().saturating_sub(table_depth));
- let statuses = self
- .list_status_recursive_if_exists(&scan_root.path)
- .await?;
+ let files = list_format_table_data_files(
+ self.table.file_io(),
+ &scan_root.path,
+ partition_levels_below_root,
+ format_extension,
+ )
+ .await?;
let mut splits = Vec::new();
- for status in statuses {
- if is_hidden_below_partitions(
- &root_segments,
- partition_levels_below_root,
- &status.path,
- ) {
- continue;
- }
- if let Some(split) = self
- .status_to_split(
- status,
- table_path,
- format_extension,
- schema_id,
- partition_fields,
- scan_root.partition.clone(),
- )
- .await?
- {
+ for status in files {
+ if let Some(split) = self.status_to_split(
+ status,
+ table_path,
+ schema_id,
+ partition_fields,
+ scan_root.partition.clone(),
+ )? {
splits.push(split);
}
}
@@ -363,27 +357,11 @@ impl<'a> FormatTableScan<'a> {
}
}
- async fn list_status_recursive_if_exists(
- &self,
- path: &str,
- ) -> crate::Result<Vec<crate::io::FileStatus>> {
- match self.table.file_io().list_status_recursive(path).await {
- Ok(statuses) => Ok(statuses),
- Err(err) => {
- if !self.table.file_io().exists(path).await.unwrap_or(true) {
- Ok(Vec::new())
- } else {
- Err(err)
- }
- }
- }
- }
-
- async fn status_to_split(
+ /// The split reading one file that [`list_format_table_data_files`]
returned.
+ fn status_to_split(
&self,
status: crate::io::FileStatus,
table_path: &str,
- format_extension: &str,
schema_id: i64,
partition_fields: &[DataField],
known_partition: BinaryRow,
@@ -393,17 +371,6 @@ impl<'a> FormatTableScan<'a> {
};
let parent = parent.to_string();
let file_name = file_name.to_string();
- if !is_format_table_data_file_name(&file_name) {
- return Ok(None);
- }
- if !file_name.to_ascii_lowercase().ends_with(format_extension) {
- return Ok(None);
- }
- let status = if status.size == 0 {
- self.table.file_io().get_status(&status.path).await?
- } else {
- status
- };
let file_size = i64::try_from(status.size).map_err(|_|
crate::Error::DataInvalid {
message: format!(
"Format table file '{}' is too large to fit in i64 metadata",
@@ -483,6 +450,59 @@ fn is_format_table_data_file_name(file_name: &str) -> bool
{
!file_name.is_empty() && !file_name.starts_with('.') &&
!file_name.starts_with('_')
}
+/// The non-hidden files with the format's extension that a Format Table scan
reads below `root`.
+/// Only a root the store reports as not found holds no files; other listing
failures are returned.
+pub(crate) async fn list_format_table_data_files(
+ file_io: &crate::io::FileIO,
+ root: &str,
+ partition_levels_below_root: usize,
+ format_extension: &str,
+) -> crate::Result<Vec<crate::io::FileStatus>> {
+ let statuses = match file_io.list_status_recursive_stream(root,
None).await {
+ Ok(listing) => collect_listing(listing).await?,
+ Err(error) if is_storage_not_found(&error) => Vec::new(),
+ Err(error) => return Err(error),
+ };
+ let root_segments = path_segments(root);
+ let mut files = Vec::with_capacity(statuses.len());
+ for status in statuses {
+ if is_hidden_below_partitions(&root_segments,
partition_levels_below_root, &status.path) {
+ continue;
+ }
+ let is_data_file =
split_parent_and_file(&status.path).is_some_and(|(_, file_name)| {
+ is_format_table_data_file_name(file_name)
+ && file_name.to_ascii_lowercase().ends_with(format_extension)
+ });
+ if !is_data_file {
+ continue;
+ }
+ let status = if status.size == 0 {
+ file_io.get_status(&status.path).await?
+ } else {
+ status
+ };
+ files.push(status);
+ }
+ Ok(files)
+}
+
+/// Every listed status, or none when the store reports the root not found
before listing anything.
+async fn collect_listing(
+ mut listing: impl futures::Stream<Item =
crate::Result<crate::io::FileStatus>> + Unpin,
+) -> crate::Result<Vec<crate::io::FileStatus>> {
+ let mut statuses = Vec::new();
+ while let Some(status) = listing.next().await {
+ match status {
+ Ok(status) => statuses.push(status),
+ Err(error) if statuses.is_empty() && is_storage_not_found(&error)
=> {
+ return Ok(statuses)
+ }
+ Err(error) => return Err(error),
+ }
+ }
+ Ok(statuses)
+}
+
/// Whether a listed file is, or lies inside, an entry whose name starts with
`.` or `_` below
/// the partition directories, such as a committer staging tree (`_temporary`,
`__magic_*`)
/// whose files may never be committed.
@@ -843,7 +863,7 @@ fn supported_format_table_formats() -> Vec<&'static str> {
]
}
-fn supported_format_table_extension(format: &str) -> crate::Result<&'static
str> {
+pub(crate) fn supported_format_table_extension(format: &str) ->
crate::Result<&'static str> {
match format.to_ascii_lowercase().as_str() {
"parquet" => Ok(".parquet"),
"orc" => Ok(".orc"),
@@ -1093,6 +1113,82 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_data_file_listing_returns_what_a_partition_scan_reads() {
+ let table = format_table("memory:/data_file_listing", &["dt"], &[]);
+ write_files(
+ &table,
+ &[
+ "dt=a/part-0.parquet",
+ "dt=a/_temporary/0/part-1.parquet",
+ "dt=a/__magic_job_1/tasks/part-2.parquet",
+ "dt=a/.part-3.parquet",
+ "dt=a/_SUCCESS",
+ "dt=a/notes.txt",
+ ],
+ )
+ .await;
+ let file_names = |files: Vec<crate::io::FileStatus>| {
+ files
+ .iter()
+ .filter_map(|file| {
+ split_parent_and_file(&file.path).map(|(_, name)|
name.to_string())
+ })
+ .collect::<Vec<_>>()
+ };
+
+ let partition = format!("{}/dt=a", table.location());
+ let listed = list_format_table_data_files(table.file_io(), &partition,
0, ".parquet")
+ .await
+ .unwrap();
+ assert_eq!(file_names(listed), vec!["part-0.parquet"]);
+ assert_eq!(
+ planned_files(&table, Some(partition_set(&table,
&[&[Some("a")]]))).await,
+ vec!["dt=a/part-0.parquet"]
+ );
+
+ // A partition whose directory is gone holds no files rather than
failing the listing.
+ let missing = format!("{}/dt=b", table.location());
+ assert!(
+ list_format_table_data_files(table.file_io(), &missing, 0,
".parquet")
+ .await
+ .unwrap()
+ .is_empty()
+ );
+ }
+
+ #[tokio::test]
+ async fn test_listing_failure_is_not_read_as_an_empty_directory() {
+ let file = crate::io::FileStatus {
+ size: 1,
+ is_dir: false,
+ path: "memory:/t/dt=a/part-0.parquet".to_string(),
+ last_modified: None,
+ };
+ let failure = |kind: opendal::ErrorKind| crate::Error::IoUnexpected {
+ message: "list partition directory".to_string(),
+ source: Box::new(opendal::Error::new(kind, "injected")),
+ };
+ // A failure after a listed file, or of any kind but not found, is not
an empty list.
+ for listing in [
+ vec![
+ Ok(file.clone()),
+ Err(failure(opendal::ErrorKind::Unexpected)),
+ ],
+ vec![Err(failure(opendal::ErrorKind::Unexpected))],
+ vec![Ok(file.clone()), Err(failure(opendal::ErrorKind::NotFound))],
+ ] {
+ assert!(collect_listing(futures::stream::iter(listing))
+ .await
+ .is_err());
+ }
+ let missing = vec![Err(failure(opendal::ErrorKind::NotFound))];
+ assert!(collect_listing(futures::stream::iter(missing))
+ .await
+ .unwrap()
+ .is_empty());
+ }
+
#[tokio::test]
async fn test_concurrent_listing_keeps_the_plan_order() {
let partitions = ["a", "b", "c", "d", "e", "f", "g", "h"];
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index a909052e..cc2e0c62 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -44,6 +44,7 @@ mod de_vector_read;
mod de_vector_scan;
mod dedicated_format_file_writer;
mod format_partition;
+mod format_partition_stats;
mod format_read_builder;
mod format_table_read;
mod format_table_scan;
@@ -134,6 +135,7 @@ pub use de_vector_scan::PreparedVectorSearchFilter;
pub use format_partition::{
format_partition_value, parse_format_partition_value,
FormatTablePartitionPaths,
};
+pub use format_partition_stats::FormatTablePartitionStatsCollector;
#[cfg(feature = "fulltext")]
pub use full_text_search_builder::FullTextSearchBuilder;
use futures::stream::BoxStream;
diff --git a/crates/paimon/tests/mock_server.rs
b/crates/paimon/tests/mock_server.rs
index 523541b8..b5b3bf56 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -1455,6 +1455,17 @@ impl RESTServer {
.unwrap_or_default()
}
+ /// Return the partitions registered for a table, statistics included, in
registration order.
+ pub fn table_partitions(&self, database: &str, table: &str) ->
Vec<Partition> {
+ self.inner
+ .lock()
+ .unwrap()
+ .partitions
+ .get(&format!("{database}.{table}"))
+ .cloned()
+ .unwrap_or_default()
+ }
+
/// Set whether a stored table is external.
pub fn set_table_external(&self, database: &str, table: &str, is_external:
bool) {
let key = format!("{database}.{table}");
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 75ac8f69..8dc81360 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -40,7 +40,7 @@ Mosaic support is always available and currently read-only.
SQL queries can read
SQL support has two layers:
- DataFusion provides the parser, query planner, optimizer, execution engine,
expressions, scalar functions, aggregate functions, and window functions. SQL
statements that `SQLContext` does not intercept are delegated to DataFusion.
This includes the DataFusion SQL surface for `SELECT` queries, CTEs (including
recursive CTEs), subqueries, joins including `LATERAL` joins, SQL lambda
functions, grouping, `HAVING`, window clauses, `QUALIFY`, set operations,
`ORDER BY`, `LIMIT`/`OFFSET`, `EX [...]
-- Paimon-specific table management and row-level writes are implemented by
`SQLContext`. This includes Paimon `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`,
`CREATE TEMPORARY TABLE`, `CREATE TEMPORARY VIEW`, REST Catalog persistent
`CREATE VIEW`, `DROP VIEW`, and `CREATE FUNCTION`, `DROP TEMPORARY TABLE` /
`VIEW`, `INSERT OVERWRITE ... PARTITION`, `UPDATE`, `DELETE`, `MERGE INTO`,
`TRUNCATE TABLE`, `ALTER TABLE ... ADD PARTITION`, `ALTER TABLE ... DROP
PARTITION`, `SHOW PARTITIONS`, `MSCK [...]
+- Paimon-specific table management and row-level writes are implemented by
`SQLContext`. This includes Paimon `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`,
`CREATE TEMPORARY TABLE`, `CREATE TEMPORARY VIEW`, REST Catalog persistent
`CREATE VIEW`, `DROP VIEW`, and `CREATE FUNCTION`, `DROP TEMPORARY TABLE` /
`VIEW`, `INSERT OVERWRITE ... PARTITION`, `UPDATE`, `DELETE`, `MERGE INTO`,
`TRUNCATE TABLE`, `ALTER TABLE ... ADD PARTITION`, `ALTER TABLE ... DROP
PARTITION`, `SHOW PARTITIONS`, `MSCK [...]
Not every DataFusion DDL/DML statement maps to a Paimon table operation. For
Paimon catalogs, `CREATE EXTERNAL TABLE`, `LOCATION`, `CREATE MATERIALIZED
VIEW`, and persistent `CREATE TABLE AS SELECT` are rejected or not implemented.
Persistent `CREATE FUNCTION` is supported only for the REST Catalog SQL scalar
form documented below. DataFusion `COPY` can export query results to files; it
does not create or commit Paimon table files.
@@ -985,6 +985,41 @@ truncated view of the table into a `DROP` diff. There is
no dry-run and no scope
argument — repair always covers the whole table. A partition at a custom
location is
never unregistered by repair.
+### ANALYZE TABLE
+
+Measure what the registered partitions hold and report it to the catalog:
+
+```sql
+ANALYZE TABLE paimon.my_db.events COMPUTE STATISTICS NOSCAN; -- files, size,
last file time
+ANALYZE TABLE paimon.my_db.events COMPUTE STATISTICS; -- also row
counts
+ANALYZE TABLE paimon.my_db.events PARTITION (dt = '2024-01-01') COMPUTE
STATISTICS;
+```
+
+Each partition is measured through the listing a scan uses, so it counts
exactly the files
+a query reads and leaves staging entries such as `_temporary` out. `NOSCAN`
stops at the
+listing: it reports the file count, the total size and the latest file
modification time.
+Without `NOSCAN` the row count is also read from each file's footer, which
Parquet and ORC
+keep; for other formats it stays unknown. A footer that cannot be read leaves
the row count
+of its partition unknown rather than short, and a partition without files
holds exactly
+zero rows. A field the statement does not measure, such as the row count under
`NOSCAN`,
+is reported as unknown (`-1`).
+
+The measurement replaces the statistics the catalog holds for each partition;
it never
+adds or removes a partition. `PARTITION (...)` must give values for a leading
run of the
+partition keys and selects every registered partition under them: on a `(dt,
region)`
+table, `PARTITION (dt = '2024-01-01')` measures every region of that date,
while
+`PARTITION (region = 'us')` is rejected. It is an error when no registered
partition
+matches. A selected partition at a custom location fails the statement, and
+`FOR COLUMNS` is not supported.
+
+A listing failure fails the statement before anything is reported.
+`format-table.statistics.parallelism` (default 8) bounds the storage requests
in flight,
+listings and footer reads alike. Set it for the session:
+
+```sql
+SET 'paimon.format-table.statistics.parallelism' = '16';
+```
+
## Procedures
Use `CALL` to invoke built-in procedures. All procedures are under the `sys`
namespace.