Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions iceberg/src/data_source.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use std::sync::Arc;

use datafusion::arrow::datatypes::SchemaRef;
use datafusion::common::{Statistics, exec_datafusion_err};
use datafusion::common::stats::Precision;
use datafusion::common::{ColumnStatistics, Statistics, exec_datafusion_err};
use datafusion::config::ConfigOptions;
use datafusion::datasource::source::DataSource;
use datafusion::error::Result;
Expand All @@ -18,10 +19,19 @@ use datafusion::prelude::Expr;
use datafusion_distributed::WorkUnitFeed;
use futures::{StreamExt, TryStreamExt};
use iceberg::arrow::ArrowReaderBuilder;
use iceberg::spec::SnapshotRef;

use crate::common::{convert_filters_to_predicate, df_err, iceberg_err};
use crate::{IcebergConfig, IcebergWorkUnitFeed};

/// Snapshot summary keys defined by the Iceberg table spec:
/// https://iceberg.apache.org/spec/#optional-snapshot-summary-fields
///
/// iceberg-rust defines them privately:
/// https://github.com/apache/iceberg-rust/blob/4168a0b2950dc5f85588e5cb3ab6796e5228b309/crates/iceberg/src/spec/snapshot_summary.rs#L46-L47
const TOTAL_RECORDS: &str = "total-records";
const TOTAL_FILE_SIZE: &str = "total-files-size";

/// Consumes a stream of [iceberg::scan::FileScanTask]s per partition and reads the underlying
/// files into an Arrow stream.
///
Expand Down Expand Up @@ -115,6 +125,7 @@ pub struct IcebergDataSource {
partitioning: Partitioning,
fetch: Option<usize>,
metrics: ExecutionPlanMetricsSet,
current_snapshot: Option<SnapshotRef>,
iceberg_file_io: iceberg::io::FileIO,
iceberg_runtime: iceberg::Runtime,
feed: WorkUnitFeed<IcebergWorkUnitFeed>,
Expand Down Expand Up @@ -148,6 +159,8 @@ impl IcebergDataSource {
.collect::<Vec<String>>()
});

let current_snapshot = table.metadata().current_snapshot().cloned();

let predicates = convert_filters_to_predicate(opts.filters);

Self {
Expand All @@ -167,6 +180,7 @@ impl IcebergDataSource {
partitioning,
sync_manager: Default::default(),
}),
current_snapshot,
}
}
}
Expand Down Expand Up @@ -249,10 +263,7 @@ impl DataSource for IcebergDataSource {
}

fn partition_statistics(&self, _partition: Option<usize>) -> Result<Arc<Statistics>> {
// TODO: Implement planning time statistics for this DataSource.
// At this point, we have information about the iceberg::table::Table which we are about
// to read, so maybe there's something we can get from there.
Ok(Arc::new(Statistics::new_unknown(&self.schema)))
stats_from_snapshot(self.current_snapshot.as_ref(), &self.schema)
}

fn with_fetch(&self, fetch: Option<usize>) -> Option<Arc<dyn DataSource>> {
Expand Down Expand Up @@ -297,3 +308,34 @@ impl DataSource for IcebergDataSource {
Ok(SortOrderPushdownResult::Unsupported)
}
}

/// Getting statistics from the provided snapshot.
fn stats_from_snapshot(
snapshot: Option<&SnapshotRef>,
schema: &SchemaRef,
) -> Result<Arc<Statistics>> {
let Some(snap) = snapshot else {
// A table with no current snapshot has never had a commit. It was created, but zero data files were added
return Ok(Arc::new(Statistics {
num_rows: Precision::Exact(0),
total_byte_size: Precision::Exact(0),
column_statistics: vec![ColumnStatistics::new_unknown(); schema.fields().len()],
}));
};
Comment on lines +317 to +324

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, if current snapshot is None, we get no statistics. This will be pretty bad, as these stats are essentially what will inform the distributed planner how much to distribute.

Is there any chance of getting the stats from somewhere else that we know it's always going to be present?

@sandugood sandugood Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed this piece of code and now populate everything with zeroes in that case.

Why do I think that this is correct? If the table was created, then we would have an entry for that table in the catalog (REST, Glue, HMS etc.). However, if no data files were committed means None for snapshot.

If table wasn't even created we would get error earlier, though.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Yeap, your reasoning sounds right. I don't know enough about Iceberg but what you say makes sense, so let's stick to it.

If you happen to have a link to some docs explaining this, cool, otherwise, it's fine to leave it like this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://iceberg.apache.org/spec/#table-metadata-fields
current-snapshot-id is an Optional parameter (for all v1-v4 of Iceberg)

Also I've tried to confirm it with a simple Iceberg table creation with Spark (without any data inserted)
After running DESCRIBE TABLE EXTENDED and checking metadata.json

tg_image_3160661423

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that within a snapshot, these stats are optional

  Option<Snapshot>
  └── Snapshot
      └── summary: Map<String, String>
          ├── optional "total-records"
          └── optional "total-files-size"
  let props = &snap.summary().additional_properties;

  let num_rows = props
      .get(TOTAL_RECORDS)
      .and_then(|v| v.parse().ok())
      .unwrap_or(0);

Here, snap exists. Only props.get(...) returns None.

The same thing happens when the value exists but cannot be parsed:

  .get(...)                 => None, or Some("invalid")
  .and_then(parse)          => None
  .unwrap_or(0)             => 0
  Precision::Exact(value)   => Exact(0)

The Iceberg spec calls these Optional Snapshot Summary Fields, including total-records and total-files-size: Iceberg specification.
https://iceberg.apache.org/spec/#optional-snapshot-summary-fields

let props = &snap.summary().additional_properties;

let num_rows = props
.get(TOTAL_RECORDS)
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let total_byte_size = props
.get(TOTAL_FILE_SIZE)
.and_then(|v| v.parse().ok())
.unwrap_or(0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Ok(Arc::new(Statistics {
num_rows: Precision::Exact(num_rows),
total_byte_size: Precision::Exact(total_byte_size),
column_statistics: vec![ColumnStatistics::new_unknown(); schema.fields().len()],
}))
}
6 changes: 5 additions & 1 deletion iceberg/src/test_utils/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use datafusion::arrow::util::pretty::pretty_format_batches;
use datafusion::dataframe::DataFrame;
use datafusion::error::Result;
use datafusion::execution::SessionStateBuilder;
use datafusion::physical_plan::displayable;
use datafusion::physical_plan::{ExecutionPlan, displayable};
use datafusion::prelude::{SessionConfig, SessionContext};
use futures::StreamExt;
use futures::stream::BoxStream;
Expand Down Expand Up @@ -58,6 +58,10 @@ impl IcebergTestHarness {
pretty_format_batches(&batches)?.to_string(),
))
}

pub async fn physical_plan(&self, sql: &str) -> Result<Arc<dyn ExecutionPlan>> {
self.ctx.sql(sql).await?.create_physical_plan().await
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
133 changes: 133 additions & 0 deletions iceberg/tests/statistics.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#[cfg(test)]
mod tests {
use std::sync::Arc;

use datafusion::common::Statistics;
use datafusion::common::stats::Precision;
use datafusion::datasource::source::DataSourceExec;
use datafusion::error::Result;
use datafusion::physical_plan::{ExecutionPlan, displayable};
use datafusion_distributed_iceberg::IcebergDataSource;
use datafusion_distributed_iceberg::test_utils::IcebergTestHarness;

// Values from testdata/iceberg/taxi/metadata/v1.metadata.json snapshot summary.
const TAXI_ROWS: usize = 175_000;
const TAXI_BYTES: usize = 4_480_382;
const TAXI_COLUMNS: usize = 13;

#[tokio::test]
async fn reports_exact_row_count_and_byte_size_for_full_scan() -> Result<()> {
let harness = IcebergTestHarness::new().await?;
let stats = source_statistics(&harness, "SELECT * FROM taxi").await?;

assert_eq!(stats.num_rows, Precision::Exact(TAXI_ROWS));
assert_eq!(stats.total_byte_size, Precision::Exact(TAXI_BYTES));
Ok(())
}

#[tokio::test]
async fn column_statistics_match_full_schema() -> Result<()> {
let harness = IcebergTestHarness::new().await?;
let stats = source_statistics(&harness, "SELECT * FROM taxi").await?;

assert_eq!(stats.column_statistics.len(), TAXI_COLUMNS);
Ok(())
}

#[tokio::test]
async fn column_statistics_match_projected_schema() -> Result<()> {
// Regression: a column_statistics vec shorter than the output schema
// makes DataFusion panic while propagating statistics upstream.
let harness = IcebergTestHarness::new().await?;
let stats = source_statistics(&harness, "SELECT vendor_id, pickup_date FROM taxi").await?;

assert_eq!(stats.column_statistics.len(), 2);
assert_eq!(stats.num_rows, Precision::Exact(TAXI_ROWS));
Ok(())
}

#[tokio::test]
async fn statistics_propagate_through_filter() -> Result<()> {
let harness = IcebergTestHarness::new().await?;
let plan = harness
.physical_plan("SELECT vendor_id FROM taxi WHERE pickup_date = DATE '2024-01-10'")
.await?;
let stats = plan.partition_statistics(None)?;

// The filter cannot keep the count exact, but it must not lose it.
assert!(matches!(stats.num_rows, Precision::Inexact(_)));
assert_eq!(stats.column_statistics.len(), 1);
Ok(())
}

#[tokio::test]
async fn statistics_propagate_through_projection_and_sort() -> Result<()> {
let harness = IcebergTestHarness::new().await?;
let plan = harness
.physical_plan("SELECT vendor_id, trip_distance FROM taxi ORDER BY pickup_at")
.await?;
let stats = plan.partition_statistics(None)?;

assert_eq!(stats.num_rows, Precision::Exact(TAXI_ROWS));
assert_eq!(stats.column_statistics.len(), 2);
Ok(())
}

#[tokio::test]
async fn explain_shows_statistics_on_the_iceberg_source() -> Result<()> {
let harness = IcebergTestHarness::new().await?;
let plan = harness.physical_plan("SELECT vendor_id FROM taxi").await?;
let display = displayable(plan.as_ref())
.set_show_statistics(true)
Comment on lines +80 to +81

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I didn't now this was an option.

.indent(true)
.to_string();

insta::assert_snapshot!(display, @"
CooperativeExec, statistics=[Rows=Exact(175000), Bytes=Exact(4480382), [(Col[0]:)]]
DataSourceExec: format=iceberg, projection=[vendor_id], statistics=[Rows=Exact(175000), Bytes=Exact(4480382), [(Col[0]:)]]
");
Ok(())
}

#[tokio::test]
async fn exact_row_count_lets_count_star_skip_the_scan() -> Result<()> {
// With Precision::Exact(num_rows) the AggregateStatistics optimizer
// rule answers COUNT(*) from metadata without reading any data file.
let harness = IcebergTestHarness::new().await?;
let (plan, batches) = harness.query("SELECT count(*) FROM taxi").await?;

insta::assert_snapshot!(plan, @"
ProjectionExec: expr=[175000 as count(*)]
PlaceholderRowExec
");
insta::assert_snapshot!(batches, @"
+----------+
| count(*) |
+----------+
| 175000 |
+----------+
");
Ok(())
}

/// Finds the single Iceberg `DataSourceExec` in the plan and returns the
/// statistics reported by the `IcebergDataSource` itself.
async fn source_statistics(harness: &IcebergTestHarness, sql: &str) -> Result<Statistics> {
let plan = harness.physical_plan(sql).await?;
let exec = find_iceberg_exec(&plan).expect("plan contains an Iceberg DataSourceExec");
Ok(Arc::unwrap_or_clone(exec.partition_statistics(None)?))
}

fn find_iceberg_exec(plan: &Arc<dyn ExecutionPlan>) -> Option<Arc<DataSourceExec>> {
if let Some(exec) = plan.downcast_ref::<DataSourceExec>() {
if exec
.data_source()
.downcast_ref::<IcebergDataSource>()
.is_some()
{
return Some(Arc::new(exec.clone()));
}
}
plan.children().into_iter().find_map(find_iceberg_exec)
}
}
Loading