mbutrovich commented on code in PR #2671:
URL: https://github.com/apache/iceberg-rust/pull/2671#discussion_r3605669905
##########
crates/integrations/datafusion/src/physical_plan/scan.rs:
##########
Review Comment:
`IcebergTableScan` is a leaf node (`children()` returns `vec![]` at
`:117-119`), so any non-empty `children` here is a caller error. Right now it
is dropped instead of surfaced. This is the same issue @timsaucer raised on the
new scan node in #2298, which you fixed there with a
`DataFusionError::Internal` matching `IcebergCommitExec`, and you noted
`IcebergTableScan::with_new_children` had the same latent bug. Since this PR is
the one that keeps and extends this node, it seems like the right place to
close it:
```rust
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> DFResult<Arc<dyn ExecutionPlan>> {
if !children.is_empty() {
return Err(DataFusionError::Internal(
"IcebergTableScan is a leaf node and cannot have
children".to_string(),
));
}
Ok(self)
}
```
##########
crates/integrations/datafusion/src/physical_plan/scan.rs:
##########
Review Comment:
Each `execute(partition)` call gets its own `remaining = limit`, so with N
output partitions the scan can emit up to `N * limit` rows before DataFusion's
top-level limit trims to `limit`. Results stay correct because a
`GlobalLimitExec` sits above the scan, but each partition reads up to `limit`
rows independently, so this is looser than the single-partition lazy path.
Either clamp the per-partition read, or add a one-line comment noting the
top-level limit makes the loose per-partition bound acceptable, so the intent
is explicit.
##########
crates/integrations/datafusion/tests/integration_datafusion_test.rs:
##########
@@ -95,6 +99,237 @@ fn get_table_creation(
Ok(creation)
}
+async fn get_multi_file_table_context(
+ namespace_name: &str,
+ table_name: &str,
+ data_file_count: usize,
+) -> Result<(Arc<MemoryCatalog>, NamespaceIdent, String)> {
+ let iceberg_catalog = Arc::new(get_iceberg_catalog().await);
+ let namespace = NamespaceIdent::new(namespace_name.to_string());
+ set_test_namespace(&iceberg_catalog, &namespace).await?;
+
+ let creation = get_table_creation(temp_path(), table_name, None)?;
+ iceberg_catalog.create_table(&namespace, creation).await?;
+
+ let write_ctx = SessionContext::new_with_config(
+ SessionConfig::new().with_target_partitions(data_file_count),
+ );
+ let arrow_schema = Arc::new(ArrowSchema::new(vec![
+ Field::new("foo1", DataType::Int32, false),
+ Field::new("foo2", DataType::Utf8, false),
+ ]));
+
+ let batches: Vec<RecordBatch> = (1..=data_file_count as i32)
+ .map(|idx| {
+ RecordBatch::try_new(arrow_schema.clone(), vec![
+ Arc::new(Int32Array::from(vec![idx])) as ArrayRef,
+ Arc::new(StringArray::from(vec![format!("row-{idx}")])) as
ArrayRef,
+ ])
+ })
+ .collect::<std::result::Result<_, _>>()?;
+
+ let partitions = batches.into_iter().map(|batch| vec![batch]).collect();
+ let source_table = Arc::new(MemTable::try_new(arrow_schema,
partitions).unwrap());
+ write_ctx
+ .register_table("source_table", source_table)
+ .unwrap();
+
+ let catalog =
Arc::new(IcebergCatalogProvider::try_new(iceberg_catalog.clone()).await?);
+ write_ctx.register_catalog("catalog", catalog);
+
+ let insert_sql =
+ format!("INSERT INTO catalog.{namespace_name}.{table_name} SELECT *
FROM source_table");
+ let batches = write_ctx
+ .sql(&insert_sql)
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(batches.len(), 1);
+
+ let rows_inserted = batches[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<UInt64Array>()
+ .unwrap();
+ assert_eq!(rows_inserted.value(0), data_file_count as u64);
+
+ Ok((iceberg_catalog, namespace, table_name.to_string()))
+}
+
+async fn get_read_context(
+ catalog: Arc<MemoryCatalog>,
+ target_partitions: usize,
+ enable_eager_scan_planning: Option<bool>,
+) -> Result<SessionContext> {
+ let ctx = SessionContext::new_with_config(read_session_config(
+ target_partitions,
+ enable_eager_scan_planning,
+ ));
+ let catalog = Arc::new(IcebergCatalogProvider::try_new(catalog).await?);
+ ctx.register_catalog("catalog", catalog);
+ Ok(ctx)
+}
+
+fn read_session_config(
+ target_partitions: usize,
+ enable_eager_scan_planning: Option<bool>,
+) -> SessionConfig {
+ let config =
SessionConfig::new().with_target_partitions(target_partitions);
+
+ match enable_eager_scan_planning {
+ Some(enabled) => {
+ let mut iceberg_config = IcebergDataFusionConfig::default();
+ iceberg_config.enable_eager_scan_planning = enabled;
+ config.with_option_extension(iceberg_config)
+ }
+ None => config,
+ }
+}
+
+async fn scan_partition_count(
+ ctx: &SessionContext,
+ namespace: &NamespaceIdent,
+ table_name: &str,
+) -> usize {
+ let provider = ctx.catalog("catalog").unwrap();
+ let namespace_name = &namespace[0];
+ let schema = provider.schema(namespace_name).unwrap();
+ let table = schema.table(table_name).await.unwrap().unwrap();
+
+ let state = ctx.state();
+ let plan = table.scan(&state, None, &[], None).await.unwrap();
+ plan.downcast_ref::<IcebergTableScan>()
+ .expect("Expected IcebergTableScan");
+ plan.properties().output_partitioning().partition_count()
+}
+
+#[tokio::test]
+async fn test_multi_file_scan_produces_multiple_partitions() -> Result<()> {
+ let data_file_count = 3;
+ // Ask for more partitions than files to verify scan planning does not
expose empty partitions.
+ let target_partitions = data_file_count + 1;
+ let (iceberg_catalog, namespace, table_name) =
get_multi_file_table_context(
+ "test_multi_file_scan_partitions",
+ "my_table",
+ data_file_count,
+ )
+ .await?;
+ let ctx = get_read_context(iceberg_catalog, target_partitions,
Some(true)).await?;
+ let actual_partition_count = scan_partition_count(&ctx, &namespace,
&table_name).await;
+
+ assert_eq!(actual_partition_count, data_file_count);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_multi_file_scan_defaults_to_single_lazy_partition() ->
Result<()> {
+ let data_file_count = 3;
+ let target_partitions = data_file_count + 1;
+ let (iceberg_catalog, namespace, table_name) =
get_multi_file_table_context(
+ "test_multi_file_scan_default_lazy",
+ "my_table",
+ data_file_count,
+ )
+ .await?;
+ let ctx = get_read_context(iceberg_catalog, target_partitions,
None).await?;
+
+ let actual_partition_count = scan_partition_count(&ctx, &namespace,
&table_name).await;
+
+ assert_eq!(actual_partition_count, 1);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_set_enable_eager_scan_planning() -> Result<()> {
+ let data_file_count = 3;
+ let target_partitions = data_file_count + 1;
+ let (iceberg_catalog, namespace, table_name) =
+ get_multi_file_table_context("test_set_eager_scan_planning",
"my_table", data_file_count)
+ .await?;
+ let ctx = get_read_context(iceberg_catalog, target_partitions,
Some(false)).await?;
+
+ ctx.sql("SET iceberg.enable_eager_scan_planning = true")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+
+ let actual_partition_count = scan_partition_count(&ctx, &namespace,
&table_name).await;
+
+ assert_eq!(actual_partition_count, data_file_count);
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn test_multi_partition_scan_matches_single_partition_results() ->
Result<()> {
Review Comment:
`test_multi_partition_scan_matches_single_partition_results` is a good check
that single- and multi-partition scans return identical rows. But
`get_multi_file_table_context` writes one row per file (`:125`,
`Int32Array::from(vec![idx])`), so every file is a single row group with no
filtering opportunity. A divergence in `row_group_filtering_enabled` or
`batch_size` between the eager and lazy paths (comment 2) would not change the
result here, so the test would still pass. A file with multiple row groups (and
ideally a predicate that prunes some of them) would turn this into a real guard
on the eager/lazy read equivalence.
##########
crates/integrations/datafusion/src/physical_plan/scan.rs:
##########
@@ -138,16 +131,57 @@ impl ExecutionPlan for IcebergTableScan {
fn execute(
&self,
- _partition: usize,
+ partition: usize,
_context: Arc<TaskContext>,
) -> DFResult<SendableRecordBatchStream> {
- let fut = get_batch_stream(
- self.table.clone(),
- self.snapshot_id,
- self.projection.clone(),
- self.predicates.clone(),
- );
- let stream = futures::stream::once(fut).try_flatten();
+ let stream: Pin<Box<dyn Stream<Item = DFResult<RecordBatch>> + Send>>
= match &self
+ .file_task_groups
+ {
+ Some(file_task_groups) => {
+ let Some(file_task_group) =
file_task_groups.get(partition).cloned() else {
+ return
Err(datafusion::common::DataFusionError::Internal(format!(
+ "IcebergTableScan partition {partition} does not
exist; scan has {} partitions",
+ file_task_groups.len()
+ )));
+ };
+
+ let tasks: FileScanTaskStream = Box::pin(futures::stream::iter(
+ (0..file_task_group.len()).map(move |idx|
Ok(file_task_group[idx].clone())),
+ ));
+ let stream = self
+ .table
+ .reader_builder()
+ // Eager planning lets DataFusion drive scan concurrency
via output
+ // partitions. Match DataFusion's FileStream model, where
each
+ // output partition owns one ScanState; keep one data file
in
+ // flight per output partition here.
+ //
https://github.com/apache/datafusion/blob/ad8e7b7f2babe3fcddc3a4f9b5cd1ac0d1b16ad9/datafusion/datasource/src/file_stream/scan_state.rs#L42-L43
+ .with_data_file_concurrency_limit(1)
+ .build()
+ // TODO: Avoid cloning FileScanTasks here once ArrowReader
can accept shared tasks.
+ .read(tasks)
+ .map_err(to_datafusion_error)?
+ .stream()
+ .map_err(to_datafusion_error);
Review Comment:
The eager arm builds its reader from `self.table.reader_builder()` and
overrides only `.with_data_file_concurrency_limit(1)`:
```rust
let stream = self
.table
.reader_builder()
.with_data_file_concurrency_limit(1)
.build()
.read(tasks)
...
```
That inherits `ArrowReaderBuilder::new` defaults
(`crates/iceberg/src/arrow/reader/mod.rs:65-73`): `row_group_filtering_enabled
= true`, `row_selection_enabled = false`, `batch_size = None`. The lazy arm at
`:174-178` reaches the reader through `TableScan::to_arrow`, which sources
those same three fields from `TableScanBuilder::new` defaults
(`crates/iceberg/src/scan/mod.rs:77-81`) and applies them at
`crates/iceberg/src/scan/mod.rs:487-492`.
So one conceptual "how to read Iceberg data files" decision is now fed by
two default sources in two different crates, with nothing pinning their
equivalence. They match today, but if a reader default is flipped in one
struct, the eager and lazy paths diverge silently in pushdown/read behavior.
This split is also what introduced the divergence: #2298's eager path went
through `TableScan::read` and shared one config source, and dropping that
(consistent with the #2298 discussion about not making `read` public) moved the
eager arm onto `ArrowReaderBuilder` defaults directly.
Two options, in preference order:
- Thread the scan's own reader config into the eager arm so `concurrency =
1` is the only intentional eager/lazy difference. This keeps a single source of
truth for the read settings.
- If that is out of scope, at minimum add a comment here stating the eager
path intentionally mirrors `to_arrow`'s reader defaults, and back it with an
equivalence test over a multi-row-group file.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]