feat(datafusion): Add opt-in eager file scan planning with output partitioning - #2671
feat(datafusion): Add opt-in eager file scan planning with output partitioning#2671toutane wants to merge 12 commits into
Conversation
|
👋 cc @mbutrovich @timsaucer |
mbutrovich
left a comment
There was a problem hiding this comment.
First pass, thanks @toutane!
|
Thanks @mbutrovich, addressed all four comments in d99603f |
6a4f1a1 to
be6d53e
Compare
|
@mbutrovich Could you please give this another look? |
be6d53e to
528019a
Compare
mbutrovich
left a comment
There was a problem hiding this comment.
Latest pass. Thanks for continuing to chip away at this, @toutane!
528019a to
2380cbf
Compare
2380cbf to
1b0504d
Compare
|
Hello @mbutrovich, thanks for this second pass! I addressed your comments in 1b0504d. |
mbutrovich
left a comment
There was a problem hiding this comment.
Latest pass, thanks @toutane!
* 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>
* 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>
* 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.
5cd31e5 to
5ae5544
Compare
|
Thank for this pass @mbutrovich! |
mbutrovich
left a comment
There was a problem hiding this comment.
Another pass, thanks for your patience on this PR, @toutane! I think it's getting close!
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.
mbutrovich
left a comment
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>() |
There was a problem hiding this comment.
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.
Thank you for continuing to review this code, @mbutrovich. |
timsaucer
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Is there an open issue for this? If so we should link here so future developers can track this TODO status.
| // 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. |
There was a problem hiding this comment.
Recommend opening an issue and linking here.
| // 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. |
There was a problem hiding this comment.
Recommend opening an issue and linking here.
@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 Thanks again for the comments - I opened three issues to track the TODOs. |
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_planningis enabled,IcebergTableProvider::scan()plansFileScanTasks during physical planning, groups them across DataFusiontarget_partitions, and exposes the resulting output partition count asUnknownPartitioning(N). Eachexecute(partition)call then reads only the task group assigned to that output partition throughArrowReaderBuilder.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
TableScanper query: the scan that plans theFileScanTasks is retained and reused by everyexecute(partition)call to derive itsArrowReaderBuilder. Both the eager and the lazy path therefore source their reader settings fromTableScan::arrow_reader_builder(), so the two cannot silently drift apart.When a
LIMITis 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 throughCoalescePartitionsExec::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 thatto_arrow()already performed, so the DataFusion integration can build a reader from a planned scan without duplicating the reader defaults.Trade-offs:
TableProvider::scan(), so it is kept opt-inFileScanTasklevel only. A table with one large file will not benefit from this changeUnknownPartitioning(N), not hash partitioning. This exposes the number of output partitions without claiming stronger partitioning semanticsTableScan, and hence itsPlanContextand warm evaluator caches, for the lifetime of the physical planFollow-up work:
file_size_in_bytes(Plan file scan task according scan file size. #128)Are these changes tested?
Yes. Integration tests cover:
test_multi_file_scan_defaults_to_single_lazy_partition)iceberg.enable_eager_scan_planning, including at runtime viaSET(test_set_enable_eager_scan_planning)test_multi_file_scan_produces_multiple_partitions)test_multi_partition_scan_matches_single_partition_results)LIMITacross multiple output partitions (test_multi_partition_scan_enforces_global_limit)ORDER BY ... LIMITacross multiple output partitions (test_multi_partition_ordered_scan_enforces_global_limit)test_iceberg_table_scan_rejects_non_empty_children)AI Disclosure
This PR was developed with AI assistance, using Claude Code (Opus 4.8)