Skip to content

feat(datafusion): Add opt-in eager file scan planning with output partitioning - #2671

Open
toutane wants to merge 12 commits into
apache:mainfrom
toutane:datafusion-eager-file-scan-planning
Open

feat(datafusion): Add opt-in eager file scan planning with output partitioning#2671
toutane wants to merge 12 commits into
apache:mainfrom
toutane:datafusion-eager-file-scan-planning

Conversation

@toutane

@toutane toutane commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

What changes are included in this PR?

This PR adds an opt-in eager scan planning path for the DataFusion integration.

When iceberg.enable_eager_scan_planning is enabled, IcebergTableProvider::scan() plans FileScanTasks during physical planning, groups them across DataFusion target_partitions, and exposes the resulting output partition count as UnknownPartitioning(N). Each execute(partition) call then reads only the task group assigned to that output partition through ArrowReaderBuilder.

The default behavior is unchanged: eager planning is disabled by default, so scans keep the existing lazy single-partition planning path unless explicitly enabled.

Eager planning builds a single TableScan per query: the scan that plans the FileScanTasks is retained and reused by every execute(partition) call to derive its ArrowReaderBuilder. Both the eager and the lazy path therefore source their reader settings from TableScan::arrow_reader_builder(), so the two cannot silently drift apart.

When a LIMIT is pushed into the scan, eager planning applies it as a per-partition bound. The final global bound is enforced above the scan by the optimized DataFusion plan, currently through CoalescePartitionsExec::fetch.

This is a narrower split from #2298, focused on file-level eager planning and output partition count reporting. Some of the broader design discussions and review feedback happened in #2298; this PR keeps only the first scoped step and intentionally does not include hash partitioning, size-aware bin-packing, or row-group/sub-file planning.

Public API changes:

Adds one public method to the core crate: iceberg::scan::TableScan::arrow_reader_builder() -> ArrowReaderBuilder. It extracts the reader construction that to_arrow() already performed, so the DataFusion integration can build a reader from a planned scan without duplicating the reader defaults.

Trade-offs:

  • Eager planning does catalog/metadata work during TableProvider::scan(), so it is kept opt-in
  • Task grouping is round-robin and count-based, not size-aware. This is simple and deterministic, but can be imbalanced when file sizes vary (tracked by [iceberg-datafusion] Balance eager scan task groups by estimated read size #2962)
  • Parallelism is at the FileScanTask level only. A table with one large file will not benefit from this change
  • The scan reports UnknownPartitioning(N), not hash partitioning. This exposes the number of output partitions without claiming stronger partitioning semantics
  • Eager planning retains the planning TableScan, and hence its PlanContext and warm evaluator caches, for the lifetime of the physical plan

Follow-up work:

Are these changes tested?

Yes. Integration tests cover:

  • eager scan planning disabled by default (test_multi_file_scan_defaults_to_single_lazy_partition)
  • enabling eager scan planning through iceberg.enable_eager_scan_planning, including at runtime via SET (test_set_enable_eager_scan_planning)
  • exposing multiple scan output partitions, clamped to the planned task count so that no empty partition is exposed (test_multi_file_scan_produces_multiple_partitions)
  • preserving query results between the lazy path, single-partition eager planning, and multi-partition eager planning (test_multi_partition_scan_matches_single_partition_results)
  • enforcing a global LIMIT across multiple output partitions (test_multi_partition_scan_enforces_global_limit)
  • enforcing a global ORDER BY ... LIMIT across multiple output partitions (test_multi_partition_ordered_scan_enforces_global_limit)
  • rejecting non-empty children on the leaf scan node (test_iceberg_table_scan_rejects_non_empty_children)

AI Disclosure

This PR was developed with AI assistance, using Claude Code (Opus 4.8)

@toutane

toutane commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

👋 cc @mbutrovich @timsaucer

@mbutrovich
mbutrovich self-requested a review July 9, 2026 15:05

@mbutrovich mbutrovich left a comment

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.

First pass, thanks @toutane!

Comment thread crates/integrations/datafusion/src/physical_plan/scan.rs Outdated
Comment thread crates/integrations/datafusion/src/table/mod.rs Outdated
Comment thread crates/integrations/datafusion/src/physical_plan/scan_planning.rs Outdated
Comment thread crates/integrations/datafusion/src/physical_plan/scan.rs Outdated
@toutane

toutane commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @mbutrovich, addressed all four comments in d99603f

@toutane
toutane force-pushed the datafusion-eager-file-scan-planning branch 2 times, most recently from 6a4f1a1 to be6d53e Compare July 10, 2026 13:44
@toutane
toutane requested a review from mbutrovich July 15, 2026 08:28
@toutane

toutane commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

@mbutrovich Could you please give this another look?

@toutane
toutane force-pushed the datafusion-eager-file-scan-planning branch from be6d53e to 528019a Compare July 17, 2026 14:28

@mbutrovich mbutrovich left a comment

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.

Latest pass. Thanks for continuing to chip away at this, @toutane!

Comment thread crates/integrations/datafusion/src/physical_plan/scan.rs
Comment thread crates/integrations/datafusion/src/physical_plan/scan.rs Outdated
Comment thread crates/integrations/datafusion/src/physical_plan/scan.rs
Comment thread crates/integrations/datafusion/tests/integration_datafusion_test.rs
@toutane
toutane force-pushed the datafusion-eager-file-scan-planning branch from 528019a to 2380cbf Compare July 22, 2026 15:26
@toutane
toutane force-pushed the datafusion-eager-file-scan-planning branch from 2380cbf to 1b0504d Compare July 27, 2026 15:37
@toutane

toutane commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Hello @mbutrovich, thanks for this second pass! I addressed your comments in 1b0504d.

@mbutrovich mbutrovich left a comment

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.

Latest pass, thanks @toutane!

Comment thread crates/integrations/datafusion/src/config.rs
Comment thread crates/integrations/datafusion/src/physical_plan/scan.rs
toutane added a commit to DataDog/iceberg-rust that referenced this pull request Jul 29, 2026
* feat(io): route FileIO storage through runtime

(cherry picked from commit 331effe)

* feat(catalog): propagate runtime to table FileIO

(cherry picked from commit 96deb60)

* feat(datafusion): propagate Iceberg runtime through providers

(cherry picked from commit 481b283)

* feat(runtime): add Datadog runtime integration hooks

* feat(datafusion): add opt-in eager scan planning

(cherry picked from commit f0b4c27)

* test(datafusion): cover lazy and eager scan planning

(cherry picked from commit 8ae6eb6)

* fix(datafusion): address eager scan review feedback

(cherry picked from commit 2380cbf)

* fix(datafusion): address eager scan review feedback

Second review round for apache#2671.

---------

Co-authored-by: Geoffrey Claude <geoffrey.claude@datadoghq.com>
toutane added a commit to DataDog/iceberg-rust that referenced this pull request Jul 29, 2026
* feat(datafusion): add opt-in eager scan planning

(cherry picked from commit f0b4c27)

* test(datafusion): cover lazy and eager scan planning

(cherry picked from commit 8ae6eb6)

* fix(datafusion): address eager scan review feedback

(cherry picked from commit 2380cbf)

* fix(datafusion): address eager scan review feedback

Second review round for apache#2671.

---------

Co-authored-by: Geoffrey Claude <geoffrey.claude@datadoghq.com>
toutane added a commit to DataDog/iceberg-rust that referenced this pull request Jul 29, 2026
* feat(datafusion): add opt-in eager scan planning

(cherry picked from commit f0b4c27)

* test(datafusion): cover lazy and eager scan planning

(cherry picked from commit 8ae6eb6)

* fix(datafusion): address eager scan review feedback

(cherry picked from commit 2380cbf)

* fix(datafusion): address eager scan review feedback

Second review round for apache#2671.
@toutane
toutane requested a review from mbutrovich July 29, 2026 15:13
@toutane
toutane force-pushed the datafusion-eager-file-scan-planning branch from 5cd31e5 to 5ae5544 Compare July 30, 2026 08:18
@toutane

toutane commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Thank for this pass @mbutrovich!

@mbutrovich mbutrovich left a comment

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.

Another pass, thanks for your patience on this PR, @toutane! I think it's getting close!

Comment thread crates/integrations/datafusion/src/physical_plan/scan.rs Outdated
toutane added 2 commits August 3, 2026 11:24
Eager scan planning built N+1 TableScans per query: one in
plan_file_task_groups to call plan_files(), then one per output partition
in execute() purely to read .arrow_reader_builder() off it.

arrow_reader_builder() reads nothing from the PlanContext, only file_io,
runtime, batch size and the row-group/row-selection flags. So the work
TableScanBuilder::build() does on those per-partition calls (snapshot
resolution, per-column schema validation, field id resolution, predicate
binding, name-mapping JSON parse, three fresh caches) was entirely
discarded.

Introduce EagerScanPlan, which keeps the Arc<TableScan> that planned the
tasks next to the tasks themselves, and have execute() derive its reader
from it. The eager path now builds one TableScan per query.

This keeps both scan paths routed through build_table_scan and
TableScan::arrow_reader_builder, so their reader settings cannot drift
apart. The eager reader is now derived from the very instance that
planned the tasks, making that equivalence structural rather than a call
convention. .build() is still called per partition, so each partition
keeps its own ArrowReader and DeleteFilter as before.
@toutane
toutane requested a review from mbutrovich August 3, 2026 13:38

@mbutrovich mbutrovich left a comment

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.

Thanks for staying with this through several rounds, this is close. Two small suggestions inline while we wait for a committer to review, both about test coverage rather than correctness of the feature itself.

/// round-robin assignment. Non-empty groups are bounded by `tasks.len()`.
// TODO: Replace this naive round-robin grouping with size-based grouping once the
// first parallel scan path is stable. Keep this v1 simple and deterministic.
fn group_file_scan_tasks_round_robin(

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.

Every test that exercises this function uses a task count equal to (or clamped down to) the partition count, so each group ends up with exactly one task. Would it be worth adding a direct #[test] here for an uneven split, e.g. 5 tasks over 3 partitions landing as [2, 2, 1]? It's a small pure function with no I/O, so a unit test seems cheap, and right now nothing would catch a change that preserves total task/row counts but breaks the round-robin balance itself.

@toutane toutane Aug 5, 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.

Addressed in f66c9b9 viatest_group_file_scan_tasks_round_robin_uneven_split

// The physical optimizer absorbs the initial GlobalLimitExec into
// CoalescePartitionsExec; its fetch enforces the global bound in the final plan.
let global_limit_coalescer = plan
.downcast_ref::<CoalescePartitionsExec>()

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.

This is a good end-to-end check that the global limit is actually enforced. One thought: it pins today's DataFusion optimizer output shape (GlobalLimitExec absorbed into CoalescePartitionsExec::fetch). If a future DataFusion bump changes how that composes, this assertion could fail with no regression in this crate's code. Might be worth a short comment here noting that a failure after a datafusion version bump likely means the plan shape changed, not that eager scanning broke, so whoever hits it later doesn't have to rediscover that.

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.

Done in ab4e8ff

@toutane

toutane commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for staying with this through several rounds, this is close. Two small suggestions inline while we wait for a committer to review, both about test coverage rather than correctness of the feature itself.

Thank you for continuing to review this code, @mbutrovich.
Do you know of any committer who might be able to take this on?

@toutane
toutane requested a review from mbutrovich August 5, 2026 11:29

@timsaucer timsaucer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think you've done a great job pushing through on this topic. I know there have been many iterations and I think it's paid off because this looks like a very clean PR. I don't have committer access, so I can only provide an extra "approve" that doesn't carry weight.

The DataFusion parts specifically look very good to me. I am unable to test at scale myself, so I will have to rely on others or on the included tests.

The comments I have are all documentation, not code issues.

// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is there an open issue for this? If so we should link here so future developers can track this TODO status.

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.

Done: #2964

// Do not cache planned FileScanTasks in the provider in v1. They are query-specific
// because projection, predicate binding, snapshot schema, and delete planning can differ
// between scans. Catalog-backed providers also need fresh metadata on each scan.
// TODO: Revisit provider-level caching for static tables with a precise cache key.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Recommend opening an issue and linking here.

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.

Done: #2963

Comment on lines +164 to +165
// TODO: Replace this naive round-robin grouping with size-based grouping once the
// first parallel scan path is stable. Keep this v1 simple and deterministic.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Recommend opening an issue and linking here.

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.

Done: #2962

@toutane

toutane commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

I think you've done a great job pushing through on this topic. I know there have been many iterations and I think it's paid off because this looks like a very clean PR. I don't have committer access, so I can only provide an extra "approve" that doesn't carry weight.

The DataFusion parts specifically look very good to me. I am unable to test at scale myself, so I will have to rely on others or on the included tests.

The comments I have are all documentation, not code issues.

@timsaucer, thanks a lot for your message, really appreciate it!

For testing, we're going to shadow real traffic in our infra. It won't be a standard benchmark, but it should at least tell us whether things are improving on real-world queries.

That said, I think having a standard way to benchmark this kind of optimization would be really valuable for the iceberg-datafusion crate. Not sure if that's already been discussed somewhere?

Thanks again for the comments - I opened three issues to track the TODOs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enable parallel file-level scanning for IcebergTableScan Datafusion Integration

3 participants