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 297965a5 feat(datafusion): support the $buckets system table (#934)
297965a5 is described below

commit 297965a5fbea25a63a3172796f85773120263b22
Author: jackylee <[email protected]>
AuthorDate: Fri Oct 2 10:08:57 2026 +0800

    feat(datafusion): support the $buckets system table (#934)
---
 .../datafusion/src/system_tables/buckets.rs        | 258 +++++++++++++++++++++
 .../datafusion/src/system_tables/mod.rs            |   4 +
 .../integrations/datafusion/tests/system_tables.rs | 118 ++++++++++
 3 files changed, 380 insertions(+)

diff --git a/crates/integrations/datafusion/src/system_tables/buckets.rs 
b/crates/integrations/datafusion/src/system_tables/buckets.rs
new file mode 100644
index 00000000..f34b6a80
--- /dev/null
+++ b/crates/integrations/datafusion/src/system_tables/buckets.rs
@@ -0,0 +1,258 @@
+// 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.
+
+//! Mirrors Java 
[BucketsTable](https://github.com/apache/paimon/blob/release-1.4/paimon-core/src/main/java/org/apache/paimon/table/system/BucketsTable.java).
+
+use std::collections::BTreeMap;
+use std::sync::{Arc, OnceLock};
+
+use async_trait::async_trait;
+use datafusion::arrow::array::{
+    Int32Array, Int64Array, RecordBatch, StringArray, 
TimestampMillisecondArray,
+};
+use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Schema, 
SchemaRef, TimeUnit};
+use datafusion::catalog::Session;
+use datafusion::datasource::memory::MemorySourceConfig;
+use datafusion::datasource::{TableProvider, TableType};
+use datafusion::error::Result as DFResult;
+use datafusion::logical_expr::Expr;
+use datafusion::physical_plan::ExecutionPlan;
+use paimon::spec::{BinaryRow, DataField};
+use paimon::table::{DataSplit, Table};
+
+use super::row_string_cast::format_row_as_java_cast_string;
+use crate::error::to_datafusion_error;
+
+pub(super) fn build(table: Table) -> DFResult<Arc<dyn TableProvider>> {
+    Ok(Arc::new(BucketsTable { table }))
+}
+
+fn buckets_schema() -> SchemaRef {
+    static SCHEMA: OnceLock<SchemaRef> = OnceLock::new();
+    SCHEMA
+        .get_or_init(|| {
+            Arc::new(Schema::new(vec![
+                Field::new("partition", ArrowDataType::Utf8, true),
+                Field::new("bucket", ArrowDataType::Int32, false),
+                Field::new("record_count", ArrowDataType::Int64, false),
+                Field::new("file_size_in_bytes", ArrowDataType::Int64, false),
+                Field::new("file_count", ArrowDataType::Int64, false),
+                Field::new(
+                    "last_update_time",
+                    ArrowDataType::Timestamp(TimeUnit::Millisecond, None),
+                    true,
+                ),
+            ]))
+        })
+        .clone()
+}
+
+#[derive(Debug)]
+pub(super) struct BucketsTable {
+    table: Table,
+}
+
+#[async_trait]
+impl TableProvider for BucketsTable {
+    fn schema(&self) -> SchemaRef {
+        buckets_schema()
+    }
+
+    fn table_type(&self) -> TableType {
+        TableType::View
+    }
+
+    async fn scan(
+        &self,
+        _state: &dyn Session,
+        projection: Option<&Vec<usize>>,
+        _filters: &[Expr],
+        _limit: Option<usize>,
+    ) -> DFResult<Arc<dyn ExecutionPlan>> {
+        let table = self.table.clone();
+        let rows =
+            crate::runtime::await_with_runtime(async move { 
collect_bucket_rows(&table).await })
+                .await
+                .map_err(to_datafusion_error)?;
+        let batch = bucket_rows_to_record_batch(&rows)?;
+        Ok(MemorySourceConfig::try_new_exec(
+            &[vec![batch]],
+            buckets_schema(),
+            projection.cloned(),
+        )?)
+    }
+}
+
+/// A bucket's files aggregated: mirrors Java `BucketEntry`.
+#[derive(Default)]
+struct BucketAgg {
+    record_count: i64,
+    file_size_in_bytes: i64,
+    file_count: i64,
+    last_update_time: Option<i64>,
+}
+
+struct BucketRow {
+    partition: Option<String>,
+    bucket: i32,
+    agg: BucketAgg,
+}
+
+async fn collect_bucket_rows(table: &Table) -> paimon::Result<Vec<BucketRow>> {
+    let scan = table
+        .new_read_builder()
+        .new_scan()
+        .with_scan_all_files()
+        .plan()
+        .await?;
+    let partition_fields = table.schema().partition_fields();
+    aggregate_bucket_rows(scan.splits(), &partition_fields)
+}
+
+/// Aggregate splits into one row per (partition, bucket).
+///
+/// Group by the raw partition row, not its rendered string. The Java
+/// cast-to-string formatter is not injective — e.g. `(p1='a, b', p2='c')` and
+/// `(p1='a', p2='b, c')` both render `{a, b, c}` — so keying on the string 
would
+/// merge unrelated partitions and report combined counts. The serialized
+/// `BinaryRow` bytes are injective and form the grouping key; the string is
+/// carried only for output. Keys still sort by partition string then bucket to
+/// match Java BucketsTable's 
`Comparator.comparing(partition).thenComparing(bucket)`,
+/// with the raw bytes as a final tie-break so distinct look-alike partitions 
keep
+/// a deterministic order.
+fn aggregate_bucket_rows(
+    splits: &[DataSplit],
+    partition_fields: &[DataField],
+) -> paimon::Result<Vec<BucketRow>> {
+    let mut aggs: BTreeMap<(Option<String>, i32, Vec<u8>), BucketAgg> = 
BTreeMap::new();
+    for split in splits {
+        let partition = format_partition(split.partition(), partition_fields)?;
+        let key = (
+            partition,
+            split.bucket(),
+            split.partition().to_serialized_bytes(),
+        );
+        let agg = aggs.entry(key).or_default();
+        for file in split.data_files() {
+            agg.record_count = agg.record_count.saturating_add(file.row_count);
+            agg.file_size_in_bytes = 
agg.file_size_in_bytes.saturating_add(file.file_size);
+            agg.file_count += 1;
+            if let Some(t) = file.creation_time.map(|t| t.timestamp_millis()) {
+                agg.last_update_time = Some(agg.last_update_time.map_or(t, 
|cur| cur.max(t)));
+            }
+        }
+    }
+    Ok(aggs
+        .into_iter()
+        .map(|((partition, bucket, _), agg)| BucketRow {
+            partition,
+            bucket,
+            agg,
+        })
+        .collect())
+}
+
+fn bucket_rows_to_record_batch(rows: &[BucketRow]) -> DFResult<RecordBatch> {
+    let n = rows.len();
+    let mut partitions = Vec::with_capacity(n);
+    let mut buckets = Vec::with_capacity(n);
+    let mut record_counts = Vec::with_capacity(n);
+    let mut file_sizes = Vec::with_capacity(n);
+    let mut file_counts = Vec::with_capacity(n);
+    let mut last_update_times = Vec::with_capacity(n);
+    for row in rows {
+        partitions.push(row.partition.clone());
+        buckets.push(row.bucket);
+        record_counts.push(row.agg.record_count);
+        file_sizes.push(row.agg.file_size_in_bytes);
+        file_counts.push(row.agg.file_count);
+        last_update_times.push(row.agg.last_update_time);
+    }
+    Ok(RecordBatch::try_new(
+        buckets_schema(),
+        vec![
+            Arc::new(StringArray::from(partitions)),
+            Arc::new(Int32Array::from(buckets)),
+            Arc::new(Int64Array::from(record_counts)),
+            Arc::new(Int64Array::from(file_sizes)),
+            Arc::new(Int64Array::from(file_counts)),
+            Arc::new(TimestampMillisecondArray::from(last_update_times)),
+        ],
+    )?)
+}
+
+/// Format `partition` as Java's cast-to-string, matching `$files`; `{}` when 
the
+/// table is not partitioned.
+fn format_partition(
+    partition: &BinaryRow,
+    partition_fields: &[DataField],
+) -> paimon::Result<Option<String>> {
+    if partition_fields.is_empty() {
+        return Ok(Some("{}".to_string()));
+    }
+    format_row_as_java_cast_string(partition, partition_fields).map(Some)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use paimon::spec::{DataType, Datum, VarCharType};
+
+    fn string_type() -> DataType {
+        DataType::VarChar(VarCharType::new(64).unwrap())
+    }
+
+    fn partition_row(a: &str, b: &str) -> BinaryRow {
+        let t = string_type();
+        let da = Datum::String(a.to_string());
+        let db = Datum::String(b.to_string());
+        BinaryRow::from_datums(&[(Some(&da), &t), (Some(&db), &t)])
+    }
+
+    fn split(partition: BinaryRow, bucket: i32) -> DataSplit {
+        DataSplit::builder()
+            .with_snapshot(1)
+            .with_partition(partition)
+            .with_bucket(bucket)
+            .with_bucket_path("/warehouse/bucket".to_string())
+            .with_data_files(Vec::new())
+            .build()
+            .unwrap()
+    }
+
+    // Two distinct partitions whose Java cast-string renders identically must
+    // still aggregate into separate rows. Grouping on the rendered string (the
+    // pre-fix behavior) would merge them into one row with combined counts.
+    #[test]
+    fn look_alike_partitions_do_not_merge() {
+        let t = string_type();
+        let fields = vec![
+            DataField::new(0, "p1".to_string(), t.clone()),
+            DataField::new(1, "p2".to_string(), t),
+        ];
+        let a = partition_row("a, b", "c");
+        let b = partition_row("a", "b, c");
+        // Precondition: the rendered strings collide, so string keying would 
merge.
+        assert_eq!(
+            format_partition(&a, &fields).unwrap(),
+            format_partition(&b, &fields).unwrap(),
+        );
+
+        let rows = aggregate_bucket_rows(&[split(a, 0), split(b, 0)], 
&fields).unwrap();
+        assert_eq!(rows.len(), 2, "distinct partitions must not be merged");
+    }
+}
diff --git a/crates/integrations/datafusion/src/system_tables/mod.rs 
b/crates/integrations/datafusion/src/system_tables/mod.rs
index 6e338f78..f3fd36f9 100644
--- a/crates/integrations/datafusion/src/system_tables/mod.rs
+++ b/crates/integrations/datafusion/src/system_tables/mod.rs
@@ -33,6 +33,7 @@ use crate::error::to_datafusion_error;
 mod aggregation_fields;
 mod audit_log;
 mod branches;
+mod buckets;
 mod consumers;
 mod files;
 mod manifests;
@@ -55,6 +56,7 @@ const TABLES: &[(&str, Builder)] = &[
     ("aggregation_fields", aggregation_fields::build),
     ("audit_log", audit_log::build),
     ("branches", branches::build),
+    ("buckets", buckets::build),
     ("consumers", consumers::build),
     ("files", files::build),
     ("manifests", manifests::build),
@@ -71,6 +73,7 @@ const SYSTEM_TABLE_NAMES: &[&str] = &[
     "aggregation_fields",
     "audit_log",
     "branches",
+    "buckets",
     "consumers",
     "files",
     "manifests",
@@ -112,6 +115,7 @@ pub(crate) fn is_registered(name: &str) -> bool {
 pub(crate) fn is_system_table_provider(provider: &dyn TableProvider) -> bool {
     provider.is::<aggregation_fields::AggregationFieldsTable>()
         || provider.is::<branches::BranchesTable>()
+        || provider.is::<buckets::BucketsTable>()
         || provider.is::<consumers::ConsumersTable>()
         || provider.is::<files::FilesTable>()
         || provider.is::<manifests::ManifestsTable>()
diff --git a/crates/integrations/datafusion/tests/system_tables.rs 
b/crates/integrations/datafusion/tests/system_tables.rs
index 84e5aa20..73dc6ba3 100644
--- a/crates/integrations/datafusion/tests/system_tables.rs
+++ b/crates/integrations/datafusion/tests/system_tables.rs
@@ -110,6 +110,7 @@ async fn test_query_auth_system_tables_fail_closed() {
         "SELECT * FROM paimon.default.qa$partitions",
         "SELECT * FROM paimon.default.qa$manifests",
         "SELECT * FROM paimon.default.qa$table_indexes",
+        "SELECT * FROM paimon.default.qa$buckets",
     ] {
         let err = query_error(&ctx, sql).await;
         assert!(
@@ -129,6 +130,7 @@ async fn test_query_auth_system_tables_fail_closed() {
         "SELECT * FROM paimon.default.qa_dynamic$partitions",
         "SELECT * FROM paimon.default.qa_dynamic$manifests",
         "SELECT * FROM paimon.default.qa_dynamic$table_indexes",
+        "SELECT * FROM paimon.default.qa_dynamic$buckets",
     ] {
         let err = query_error(&ctx, sql).await;
         assert!(
@@ -1611,3 +1613,119 @@ async fn test_aggregation_fields_system_table() {
         "a field with no fields.* option has no option key"
     );
 }
+
+#[tokio::test]
+async fn test_buckets_system_table() {
+    let (ctx, _catalog, _tmp) = create_context().await;
+
+    // Ground truth: aggregate the tested $files table by (partition, bucket).
+    // $buckets must reproduce it exactly (same partition rendering, same 
per-file values).
+    let files = run_sql(
+        &ctx,
+        &format!(
+            "SELECT partition, bucket, record_count, file_size_in_bytes \
+             FROM paimon.default.{FIXTURE_TABLE}$files"
+        ),
+    )
+    .await;
+    let mut expected: std::collections::BTreeMap<(String, i32), (i64, i64, 
i64)> =
+        std::collections::BTreeMap::new();
+    for batch in &files {
+        let parts = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap();
+        let buckets = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let rc = batch
+            .column(2)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        let sz = batch
+            .column(3)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        for i in 0..batch.num_rows() {
+            let e = expected
+                .entry((parts.value(i).to_string(), buckets.value(i)))
+                .or_default();
+            e.0 += rc.value(i);
+            e.1 += sz.value(i);
+            e.2 += 1;
+        }
+    }
+    assert!(!expected.is_empty(), "fixture should contain data files");
+
+    let sql = format!("SELECT * FROM paimon.default.{FIXTURE_TABLE}$buckets");
+    let batches = run_sql(&ctx, &sql).await;
+    assert!(!batches.is_empty(), "$buckets should return ≥1 batch");
+
+    let arrow_schema = batches[0].schema();
+    let expected_columns = [
+        ("partition", DataType::Utf8),
+        ("bucket", DataType::Int32),
+        ("record_count", DataType::Int64),
+        ("file_size_in_bytes", DataType::Int64),
+        ("file_count", DataType::Int64),
+        (
+            "last_update_time",
+            DataType::Timestamp(TimeUnit::Millisecond, None),
+        ),
+    ];
+    for (i, (name, dtype)) in expected_columns.iter().enumerate() {
+        let field = arrow_schema.field(i);
+        assert_eq!(field.name(), name, "column {i} name");
+        assert_eq!(field.data_type(), dtype, "column {i} type");
+    }
+
+    let mut actual: std::collections::BTreeMap<(String, i32), (i64, i64, i64)> 
=
+        std::collections::BTreeMap::new();
+    for batch in &batches {
+        let parts = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap();
+        let buckets = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let rc = batch
+            .column(2)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        let sz = batch
+            .column(3)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        let fc = batch
+            .column(4)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        for i in 0..batch.num_rows() {
+            let prev = actual.insert(
+                (parts.value(i).to_string(), buckets.value(i)),
+                (rc.value(i), sz.value(i), fc.value(i)),
+            );
+            assert!(
+                prev.is_none(),
+                "$buckets must emit one row per (partition, bucket)"
+            );
+        }
+    }
+
+    assert_eq!(
+        actual, expected,
+        "$buckets must aggregate $files by (partition, bucket)"
+    );
+}

Reply via email to