Repository navigation
feat(iceberg): scan Iceberg tables in Ballista - #2638
NoahKusaba wants to merge 40 commits into
Conversation
dcb2d25 to
8824ff7
Compare
register_iceberg(state) wraps the session's catalogs so that DataFusion's async table lookup, which runs once per table a query names, loads each catalog-backed Iceberg table and returns it at that version. Every scan of a table in one query reads the same version, and the logical codec no longer loads tables, so it no longer blocks the client or needs a multi-threaded runtime.
Flight SQL clients send SQL that the scheduler plans, so they hold no catalog. iceberg_session_builder builds the scheduler's sessions with configured catalogs mounted and readied with register_iceberg, so their tables are pinned while the scheduler plans. register_iceberg now wraps the codecs itself and leaves an already-ready session unchanged, so register_iceberg_codecs is gone. The docs now say a separately deployed scheduler and executors set the Iceberg codecs as their codec overrides, and that a view keeps the version from when it was created.
A metadata table such as t$snapshots reached the inner codec, which failed with an internal error suggesting a DataFusion bug. The logical codec now refuses it as not supported on Ballista yet.
iceberg_session_builder now takes the scheduler's existing session builder and wraps it, so a scheduler that sets up its sessions itself (for example to register object stores) keeps that setup. Also explains the dead_code allow on the shared test helpers.
iceberg_session_builder now takes and returns Arc<dyn Fn>, the shape SchedulerConfig stores, so an existing builder passes straight in and the result goes straight to with_override_session_builder. The Flight SQL docs now link to mounting Iceberg catalogs.
SessionStateBuilder::new_from_existing gives a session a new ID, so register_iceberg now keeps the original, as Ballista's own share_file_statistics_cache does. Also makes the SessionBuilder alias private: ballista-scheduler already exports one.
|
PR is ready for those interested, as the description says, we will need to wait for new releases for datafusion-iceberg and iceberg-rust crate. |
Payloads that aren't Iceberg's now pass to the inner codec untouched, so codecs for other table formats that follow the same convention can be stacked in any order. Document the convention in the user guide.
| Only the node that plans a query talks to a catalog: the client, or the | ||
| scheduler for SQL clients. Executors never need catalog configuration. | ||
|
|
||
| ## Combining with other table formats |
There was a problem hiding this comment.
Hey @wirybeaver, we need to establish a convention that lets different table-providers interop with each other, so for example, a single query could read Hudi and Iceberg tables together.
This is the standard I'm trying to establish: have HudiLogicalExtensionCodec::new(inner) wrap config.ballista_logical_extension_codec() instead of hard-coding BallistaLogicalExtensionCodec, and forward every method to inner, including the UDF ones.
Let me know if this works for you.
There was a problem hiding this comment.
@NoahKusaba, the codec composition convention makes sense, including forwarding the UDF methods. I think we should establish the build and release boundary first, before landing individual format integrations, including the codec composition.
This was the motivation behind my proposal in #1241 and the split between #2388 and #2389: a minimal, SDK-free, opt-in build gate on the Ballista side, with the actual format integration owning its dependencies separately. The same convention should apply to Iceberg, Hudi, Lance, and Delta.
Your putting Iceberg in a separate crate is a useful step, but keeping it in Ballista’s root workspace and lockfile still couples dependency resolution. For example, Ballista may upgrade DataFusion before a format’s adapter supports that version. We should be able to release Ballista without waiting for every format integration to catch up.
I’d suggest establishing the minimal gate first, with format SDKs and implementations outside Ballista’s main workspace, root lockfile, and release graph, then adding Iceberg against that boundary. Each integration could maintain its own compatibility requirements and release cadence.
The gate itself would not make incompatible DataFusion versions interoperable; it would keep a format adapter’s compatibility constraints from becoming a prerequisite for Ballista’s build and release. Thus, the Codec composition comes to the rescue. Codec composition would then provide interoperability among integrations built against compatible versions.
There was a problem hiding this comment.
@andygrove, could you also take another look at whether it would make sense to reopen #2388 and #2389, with revisions as needed?
Since the earlier discussion, hudi-rs has matured further, including the merged DataFusion 55 / Arrow 59 compatibility work in apache/hudi-rs#698. I also now have a standalone Ballista–Hudi integration exercising the existing extension points.
With Iceberg integration progressing too, I think this is a useful time to revisit the shared build and release convention. #2388 could serve as the starting point for the minimal SDK-free gate, and #2389 could demonstrate the separately maintained format implementation. Establishing that boundary first would give Iceberg, Hudi, Lance, and Delta a consistent integration model while keeping their dependency and release schedules separate from Ballista’s.
I understand the earlier maintainer-bandwidth concern still applies. Would you be open to revisiting those PRs around this build and release boundary?
There was a problem hiding this comment.
I still see no reason to bring Hudi as dependency
There was a problem hiding this comment.
Would standard composable codec work with multiple codecs?
There was a problem hiding this comment.
@milenkovicm, I would separate this into two priorities:
P0: Establish the build and release boundary for Iceberg
Composable codecs address interoperability between integrations built against compatible dependencies. They do not establish an independent build or release boundary.
Suppose Ballista upgrades its DataFusion/Arrow stack while the Iceberg adapter still exposes types from the previous versions. Cargo can resolve both versions, but the integration may fail to compile where those types cross the adapter boundary.
This PR includes Iceberg in the root workspace and inherits Ballista’s dependencies, so that failure would affect workspace-wide CI. Keeping the build green would then require updating the adapter alongside Ballista or explicitly excluding it. That coordination burden is my concern; it does not mean the core crates are inherently unpublishable.
I suggest establishing an explicit opt-in build gate and independent release policy first: the integration should have its own workspace, lockfile, and CI, and remain on a compatible Ballista release until it is ready to upgrade. A feature flag alone is insufficient. Whether this requires an additional core gate or can use the existing extension points is something we can settle around that requirement.
We can focus entirely on Iceberg for this step.
P2: Consider other formats later
Hudi’s record-location indexes support efficient key-based upserts, while Lance’s scalar indexes can accelerate merge-insert operations. These offer useful capabilities for CDC and frequent-update workloads, and users may want Ballista to query those datasets.
We can defer those integrations. The immediate question is how to integrate Iceberg without making its dependency upgrade schedule part of Ballista’s core release obligations.
There was a problem hiding this comment.
Following up on the P0/P2 discussion, I have made the proposed boundary concrete in two PRs:
- Upstream merge proposal: feat: add an SDK-free Iceberg integration build gate #2731. Adds an opt-in, SDK-free Iceberg codec gate and documents the independent build/release boundary. It introduces no Iceberg SDK dependencies or root lockfile changes.
- Personal-fork demo: wirybeaver/datafusion-ballista#12. Adapts @NoahKusaba's implementation from feat(iceberg): scan Iceberg tables in Ballista #2638 to the gate in an excluded workspace with its own SDK dependencies, lockfile and CI. It is a draft demonstration, not intended for upstream merge and not a replacement for feat(iceberg): scan Iceberg tables in Ballista #2638.
The demo is stacked on the gate branch, so its diff isolates the integration changes. Its path dependencies exercise the unpublished gate; independently released adapters would select compatible released Ballista versions. This separates dependency resolution and release ownership, without claiming compatibility between different DataFusion/Arrow types.
…lone-scans # Conflicts: # Cargo.lock
|
Thanks @NoahKusaba will try to review it tomorrow |
milenkovicm
left a comment
There was a problem hiding this comment.
Thanks @NoahKusaba this is going to be a great addition. Few comments to start with
| Only the node that plans a query talks to a catalog: the client, or the | ||
| scheduler for SQL clients. Executors never need catalog configuration. | ||
|
|
||
| ## Combining with other table formats |
There was a problem hiding this comment.
I still see no reason to bring Hudi as dependency
| Only the node that plans a query talks to a catalog: the client, or the | ||
| scheduler for SQL clients. Executors never need catalog configuration. | ||
|
|
||
| ## Combining with other table formats |
There was a problem hiding this comment.
Would standard composable codec work with multiple codecs?
| codecs can be stacked in either order: | ||
|
|
||
| ```rust | ||
| let logical = IcebergLogicalCodec::new(Arc::new(DeltaLogicalCodec::new( |
There was a problem hiding this comment.
What's the benefits of this approach over https://docs.rs/datafusion-proto/latest/datafusion_proto/physical_plan/struct.ComposedPhysicalExtensionCodec.html ?
There was a problem hiding this comment.
ComposedPhysicalExtensionCodec only covers the physical codec. DataFusion has no logical counterpart, and Iceberg tables travel from the client to the scheduler through the logical codec, as table providers in the logical plan. So it can't combine Iceberg with another table format on that hop. The physical side alone wouldn't be enough.
Each codec wrapping the one inside it works the same way for both the logical and the physical codecs. It also fits Ballista's one-codec-per-session config: register_iceberg wraps whatever codec the session already has. And because each codec recognises its own prefix, nodes don't need to list codecs in the same order, whereas the composed codec labels payloads by list position.
There was a problem hiding this comment.
I'm not sure if just wrapping codecs is correct as there might be a cases where wrong codec can decode message not intended for it, please have a look at datafusion python there is a lot of work around codec composition there
There was a problem hiding this comment.
You're right that a malicious/incorrect codec that is registered can decode messages not intended for it.
The alternative you're bringing up to copy datafusion-python's identity envelope design, would require a core-ballista/datafusion*** change (which I'm trying to avoid as it isn't entirely necessary).
I'll get you a draft up for you to consider.
Going to tinker with the design more.
There was a problem hiding this comment.
why can't we just copy ComposedPhysicalExtensionCodec and make ComposedLogicalExtensionCodec the whole point is to remember position of encoder ?
There was a problem hiding this comment.
I promise to dig into that (my schedule is hectic recently).
You're thinking we implement ComposedLogicalExtensionCodec into datafusion-proto correct? Not just keeping it in ballista-core?
There was a problem hiding this comment.
we can start from here and move it to datafusion, lets see.
no rush i understand you
There was a problem hiding this comment.
Now uses ComposedExtensionCodec stuff. Should be good for you to review again.
…nto feat/iceberg-standalone-scans
andygrove
left a comment
There was a problem hiding this comment.
This has come a long way, nice work. Since most of the diff is the lockfile, I don't think it needs a big split. One thing I'd consider pulling into a follow-up is the view re-pinning from 9025298. It came in after the last round and it's the trickiest bit of plan rewriting here, so I'd like to give it a focused review on its own. Landing views with the pinned-at-creation behavior documented as a limitation first works for me. Up to you though, happy to review it here if you'd rather keep it together.
Small nit on the description: it says the testcontainers tests run in the existing linux-test job, but since the move to its own workspace they run in iceberg.yml.
Reverts 9025298 so it can be reviewed on its own. Views keep the version from when they were created, documented as a limitation.
Depend on the Ballista crates at a commit that includes apache#2752 instead of the crates beside it, and stop running the workflow on changes under ballista/, so a dependency bump in Ballista can't break this build.
Ballista restricts each task to its partitions only for the scans it knows, so each task would read every partition of an Iceberg scan and return duplicate rows. Fail loudly once a dependency bump starts splitting scans.
|
Thanks! Split the view re-pinning into #2784 (draft, stacked on this), and fixed the description: the testcontainers tests run in |
andygrove
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround on the last round. The git pin, the trigger change, the partition guard and the note on the catalog wrapper all look good, and moving the view work into #2784 made this much easier to review. I built the crate and ran some extra queries against it. I found one case that returns wrong results, which I think needs sorting out before this lands, plus a few smaller things inline.
| // knows, such as file scans, so each task would read every partition | ||
| // of this one and rows would come back duplicated. | ||
| let partitions = node.output_partitioning().partition_count(); | ||
| if partitions > 1 { |
There was a problem hiding this comment.
I think the new guard covers one shape of this, but not all of them. A one-partition Iceberg scan under a UNION ALL still gets read by every task when the adaptive planner splits the union's stage. The task that owns the other branch asks the Iceberg leaf for zero partitions. restrict_plan_to_partitions doesn't know IcebergDataSource, so the leaf keeps its one partition and that task reads the whole table again.
I reproduced it on standalone Ballista with SELECT id, name FROM t UNION ALL SELECT id, name FROM t ORDER BY id. With ballista.scheduler.max_partitions_per_task = 1, every row comes back 4 times instead of 2. Default settings do the same when the executor has fewer free vcores than the union has partitions (I used ballista.standalone.parallelism = 1), which is normal on a busy cluster. A union of an Iceberg table and a Parquet table counted 9 rows instead of 6. The static planner gets it right, because can_stay_inline gives the branch its own stage. None of the current tests run a union, and an idle executor with enough vcores runs the whole stage as one task anyway.
The root cause is in Ballista core and is tracked in #2679. #2721 turns this into an error and adds a resolver on SchedulerConfig, so SchedulerConfigExt::with_iceberg could restrict the Iceberg leaf itself. It would keep the scan for [0] and swap in an empty zero-partition MemorySourceConfig for []. Would you be OK holding this until #2721 lands, then bumping the pin and adding a UNION ALL test? The standalone scheduler can't take a resolver, so the test would need to go through a scheduler readied with with_iceberg, like the one in tests/flight_sql.rs, with an executor that has fewer vcores than the union has branches. The docs line saying a multi-partition scan fails "rather than being read in full by every task" is also worth updating, since it reads as if a one-partition scan is always safe. Happy to share my repro test.
A table's metadata grows with each snapshot, and every scan of it carried a copy, so a self-join on a table with a long history could exceed the gRPC message limit. Send only the metadata file's location: the table cache keeps the extra read to once per version.
Which issue does this PR close?
Part of #2174: the first step of its integration foundation, Iceberg scans and codec support, for Rust clients and Flight SQL clients. Split out of #2217 as its smallest reviewable piece: reads only, built on merged upstream code. Related: #1241.
Rationale for this change
datafusion-iceberg plans Iceberg scans, but its tables and scans hold live state (a loaded
Tableand itsFileIO), so Ballista can't send them to the scheduler and executors. This PR adds codecs that can.What changes are included in this PR?
A new crate,
ballista-iceberg(integrations/iceberg), with a logical and a physical extension codec. A client readies its session withregister_iceberg(state); a separately deployed scheduler and executors are readied withSchedulerConfigExt::with_icebergandExecutorProcessConfigExt::with_iceberg(theschedulerandexecutorfeatures). No changes to Ballista core.FileIO. Metadata files are immutable, so every node rebuilds exactly that version without a catalog. Only the location is sent, not the metadata, so a plan's size doesn't grow with the table's history; each node reads a version's metadata once and caches it.IcebergCatalogTableProvider, including the ones a mountedIcebergCatalogProviderserves) are pinned on the client while the query is planned.register_icebergwraps the session's catalogs so that DataFusion's async table lookup, which runs once per table a query names, loads the table and returns it at that version. Every scan of a table in one query, such as both sides of a self-join, reads the same version; each new query reads the latest one; and the scheduler and executors need no catalog configuration. A view keeps the version from when it was created; feat(iceberg): re-pin a view's Iceberg tables each time a query names it #2784 re-pins its tables on each query. Encoding does no I/O, so the client can run on any tokio runtime.IcebergStaticTableProvider) send the version they hold, for time travel.SchedulerConfig::with_iceberg(catalogs)wraps the scheduler's session builder to mount Iceberg catalogs on its sessions, pins their tables there the same way, and adds the Iceberg codecs. This also gives the scheduler the catalogs that scheduler-side commits ([Iceberg] Run a job's final commit step on the scheduler, not an executor #2622) will need.register_icebergandwith_icebergadd the Iceberg codecs after the session's existing codecs, with Ballista'sComposedLogicalExtensionCodec(feat: add ComposedLogicalExtensionCodec #2752) and DataFusion'sComposedPhysicalExtensionCodec. Each payload records which codec wrote it, so other Ballista extensions keep working, and a driver for another table format, such as Delta, maintained outside this repo, can be composed alongside, so one query can join tables from both. The Iceberg codecs handle only Iceberg tables and scans, and fail on everything else.FileIO, so a catalog that vends new credentials on every load makes each query rebuild the table. The cap bounds memory, since a table with a long history can hold megabytes of metadata. It isn't configurable yet: a session setting can't govern a per-process cache, and a constructor option can be added later without breaking anything.Known limitations, also listed in the docs:
INSERT INTOfails with "isn't supported on Ballista yet".DataSourceto each task's partitions and every task would read all of them. A one-partition scan in a stage the adaptive planner splits between tasks, such as underUNION ALL, has the same problem; the fix needs feat: let custom ExecutionPlan nodes restrict themselves to a task's partitions #2721 (see the review thread).DataFrameor Flight SQL prepared statement run twice reads one version.ctx.catalog(name)returns the pinning wrapper, so it doesn't downcast to the registered catalog's type; keep theArcyou registered.t$snapshots).encryption.key-id): the scheduler and executors have no key management client.with_icebergonce, passing the scheduler every catalog; a second call adds the codecs again.Dependencies: the Ballista crates are pinned to a commit on
mainthat includes #2752, and move to crates.io once a release includes it. iceberg-rust and datafusion-iceberg are pinned to git revisions until they release, with iceberg at the revision datafusion-iceberg pins so the build resolves a single iceberg crate. The crate ispublish = falseand needs Rust 1.95; Ballista itself still builds on 1.94.Release boundary: as suggested in #2174, the crate is its own workspace, excluded from Ballista's like
python/, with its ownCargo.lockand CI workflow (.github/workflows/iceberg.yml). Ballista's rootCargo.tomlchanges only by that exclusion and itsCargo.lockdoesn't change. Because the crate depends on a pinned Ballista commit rather than the crates beside it, and the workflow doesn't run on changes underballista/, a DataFusion upgrade iceberg-rust doesn't support yet doesn't block Ballista's build or release.Are these changes tested?
Yes:
INSERTinto it is refused;register_iceberg;SchedulerConfigExt::with_icebergand an executor readied withExecutorProcessConfigExt::with_iceberg, and sees a commit made between two queries.testcontainersfeature, whichiceberg.ymlenables with--all-features.All end-to-end tests run on a single-threaded tokio runtime.
Are there any user-facing changes?
Yes, a new crate and a docs page:
docs/source/user-guide/iceberg.md, covering usage, time travel, SQL clients, how it works and the limitations;cargo run --example standalone-iceberg-scanfromintegrations/iceberg.No existing APIs change.