Skip to content

feat: add IcebergCatalogTableProvider::new_with_schema - #48

Closed
NoahKusaba wants to merge 1 commit into
apache:mainfrom
NoahKusaba:feat/provider-new-with-schema
Closed

NoahKusaba wants to merge 1 commit into
apache:mainfrom
NoahKusaba:feat/provider-new-with-schema

Conversation

@NoahKusaba

@NoahKusaba NoahKusaba commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

A distributed engine like Ballista serializes a catalog-backed IcebergCatalogTableProvider in one process and rebuilds it in another, such as its scheduler. #20 exposes the provider's parts (catalog(), table_ident() and schema()). The only public constructor, try_new, reloads the table and takes the schema the table has now.

A codec can't use try_new for the rebuild. datafusion-proto's LogicalExtensionCodec::try_decode_table_provider is synchronous and is handed the serialized schema. The decoder resolves the scan's projection against that schema, then applies the resulting indices to the rebuilt provider's schema(). So the rebuilt provider has to declare exactly the original's schema, and it can't load the table there without blocking.

What changes are included in this PR?

  • Adds IcebergCatalogTableProvider::new_with_schema(catalog, table_ident, schema). It builds a provider that declares schema, without loading the table. schema is not checked against the table.
  • try_new now loads the schema and delegates to new_with_schema, and its docs gain an # Errors section.

Are there any user-facing changes?

One new public constructor. Nothing breaks.

Are these changes tested?

Yes, by extending existing integration tests:

  • test_plan_nodes_are_inspectable now rebuilds the provider from its accessors with new_with_schema, the way a codec would.
  • test_scan_after_schema_evolution_reads_provider_columns also scans through a provider rebuilt after the column is added. It returns the same columns as the original.

Comment thread crates/datafusion/src/table/mod.rs Outdated
/// assert_eq!(rebuilt.schema(), original.schema());
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// # })?;
/// # Ok::<(), Box<dyn std::error::Error>>(())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This example here seems unreasonably long and verbose for a method that is just:

    pub fn new_with_schema(
        catalog: Arc<dyn Catalog>,
        table_ident: TableIdent,
        schema: ArrowSchemaRef,
    ) -> Self {
        IcebergTableProvider {
            catalog,
            table_ident,
            schema,
        })
    }

TBH, for a public method like this, I don't think it needs more than a couple of lines of docs. An explanation of how IcebergTableProvider works is better given as a doc comment of the IcebergTableProvider struct itself, rather than a constructor method.

Comment thread crates/datafusion/src/table/mod.rs Outdated
Comment on lines +1115 to +1121
let expected = "+----+------+\n\
| id | name |\n\
+----+------+\n\
| 1 | a |\n\
+----+------+";
assert_eq!(query(&rebuilt_ctx, "SELECT * FROM t").await, expected);
assert_eq!(query(&ctx, "SELECT * FROM t").await, expected);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should not do these kind of expectations here, prefer using insta so that expectations over ascii graphics are automatically managed.

@andygrove andygrove 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.

Thanks @NoahKusaba. The constructor is the right shape for a codec. datafusion-proto's LogicalExtensionCodec::try_decode_table_provider is synchronous and is handed the serialized schema. The decoder resolves the scan's projection against that schema, then applies those indices to the rebuilt provider's schema(). So the rebuilt provider has to declare exactly the original's schema, and it can't load the table there without blocking. It would help to say that in the description, because it answers "why not call try_new on the scheduler?".

A few things before this can merge:

1. Rebase onto main. #49 renamed the provider to IcebergCatalogTableProvider and moved it to table_provider/catalog_table_provider.rs, so this PR now conflicts. The doctest's use datafusion_iceberg::IcebergTableProvider no longer resolves. The port is mechanical. I tried it locally, and fmt, clippy, rustdoc and the full test suite pass on top of main.

2. Docs. I agree with @gabotechs that the example can go. It's mostly hidden setup to show that a three-field constructor round-trips, and #50 already cut the similar example on new_with_predicate down to a few lines. Two things in the text are also off:

  • "A name the table lacks fails the scan" should refer to the schema of the snapshot being scanned, not the table's current schema (#42). For example, a provider built with try_new after a column is added, but before any write, fails with Column email not found in table, even though the table has email.
  • Nothing checks schema against the table, and a mismatch doesn't fail cleanly. With id: Int64 declared over an Int32 column, SELECT id + 1 panics in arrow (primitive array). The docs should say the schema isn't validated, rather than imply that it's safe "even after the table's schema has changed".

Something like:

/// Creates a provider for `table_ident` that declares `schema`, without loading
/// the table. For rebuilding a provider in another process from [`Self::catalog`],
/// [`Self::table_ident`] and [`TableProvider::schema`]. `schema` is not checked
/// against the table; scans read its columns by name from the snapshot they scan.

3. Tests. For the snapshot assertions: the workspace doesn't depend on insta, but expect-test is already a dev-dependency and the integration tests use expect![[...]], so that avoids adding a dependency. The integration tests also already cover most of this:

  • test_plan_nodes_are_inspectable rebuilds a provider from its accessors with try_new. With this PR, a codec would make that call with new_with_schema instead.
  • test_scan_after_schema_evolution_reads_provider_columns builds a provider, adds a column, and scans.

Switching that rebuild to new_with_schema, and scanning through a rebuilt provider at the end of the evolution test, would cover this in a few lines. That would replace the new unit test, the query helper and the doctest.

4. Heads-up, not for this PR. A rebuilt provider inherits the cached-schema bugs filed as #56 and #57. After a column is added to the table, INSERTs through a provider built before the change fail. After a column is dropped and added back under the same name, IS NULL counts come out wrong, and queries panic if the type changed. Ballista runs DML through the rebuilt provider, so it will hit these as soon as another engine changes a table's schema.

Nit: the PR title needs the new type name after the rebase.

@NoahKusaba

NoahKusaba commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review @andygrove, I was going to wait for #18 to also merge (along with the other refactors), but to be fair it isn't a real blocker so I've addressed all of the comments in the new commit.

Small note on the doc example I provided that is now removed, I was asked to provide one for public api's in previous PR's, but I agree it's unnecessary.

I'll start working on the standalone table-scan PR, and will hopefully have the PR up on Ballista maybe by Monday (I'm mostly done, I just need to cut it out from the main ballista-iceberg PR and update dependencies + review).

@NoahKusaba
NoahKusaba force-pushed the feat/provider-new-with-schema branch from 76d5c61 to 0eb5e78 Compare October 11, 2026 03:58
@NoahKusaba NoahKusaba changed the title feat: add IcebergTableProvider::new_with_schema feat: add IcebergCatalogTableProvider::new_with_schema Oct 11, 2026
@gabotechs

Copy link
Copy Markdown
Contributor

Just merged #18! about the snapshot testing, DataFusion uses insta (https://datafusion.apache.org/contributor-guide/testing.html#snapshot-testing-cargo-insta), which is similar to expect-test but with some more features.

For consistency with Apache DataFusion, I think we should move this project out of expect-test into insta. DataFusion contributors will be more familiar with that tool, and we'll bridge a bit the gap between the testing approaches in both projects.

@andygrove andygrove 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.

LGTM, thanks @NoahKusaba!

@gabotechs, I agree on moving to insta for consistency with DataFusion. It probably fits best in a follow-up that converts the existing expect! snapshots, rather than in this PR.

Also, the files that #18 added are missing the ASF license header, which the RAT check in #32 will flag. Could you add it to these?

  • crates/datafusion/src/test_utils/mod.rs
  • crates/datafusion/src/test_utils/harness.rs
  • crates/datafusion/tests/external_table.rs
  • crates/datafusion/tests/filter_pushdown.rs
  • crates/datafusion/tests/limit_pushdown.rs
  • crates/datafusion/tests/projection_pushdown.rs
  • crates/datafusion/testdata/iceberg/taxi/README.md

@andygrove

Copy link
Copy Markdown
Member

LGTM, thanks @NoahKusaba!

@gabotechs, I agree on moving to insta for consistency with DataFusion. It probably fits best in a follow-up that converts the existing expect! snapshots, rather than in this PR.

Also, the files that #18 added are missing the ASF license header, which the RAT check in #32 will flag. Could you add it to these?

  • crates/datafusion/src/test_utils/mod.rs
  • crates/datafusion/src/test_utils/harness.rs
  • crates/datafusion/tests/external_table.rs
  • crates/datafusion/tests/filter_pushdown.rs
  • crates/datafusion/tests/limit_pushdown.rs
  • crates/datafusion/tests/projection_pushdown.rs
  • crates/datafusion/testdata/iceberg/taxi/README.md

I also created a separate PR split out of #32 to add the RAT checks. #60

@NoahKusaba

Copy link
Copy Markdown
Contributor Author

Can we merge this in? I'll create the follow up test fix in the next PR.

Builds a catalog-backed provider from its catalog, table identifier and
schema without loading the table, so a codec can rebuild a provider in
another process that declares exactly the original's schema.
@NoahKusaba
NoahKusaba force-pushed the feat/provider-new-with-schema branch from 0eb5e78 to c8c9171 Compare October 11, 2026 16:35
@NoahKusaba
NoahKusaba requested a review from andygrove October 11, 2026 16:45
@NoahKusaba

Copy link
Copy Markdown
Contributor Author

cc @gabotechs

@gabotechs gabotechs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1 for unblocking, but I have to say I don't love that we make inconsistent state representible in the public API

Comment on lines +98 to +103
pub fn new_with_schema(
catalog: Arc<dyn Catalog>,
table_ident: TableIdent,
schema: ArrowSchemaRef,
) -> Self {
IcebergCatalogTableProvider {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't love the fact that the public API of this project makes incorrect state representable.

People here can choose to shoot themselves in the foot and pass a schema that has nothing to do with the actual table. This problem goes a bit beyond just this new constructor method, and it's rooted into the IcebergCatalogTableProvider struct fields themselves.

One thing I notice is that IcebergCatalogTableProvider only needs the Catalog itself because it's required for constructing an IcebergCommitExec. It's a bit unfortunate that Catalog is dangerously close to a god object in charge of everything, some interface segregation there could have prevented from needing to thread it around everywhere.

Given that Catalog contains so many methods and not necessarily related functionality, I wonder if it'd make sense to have it be part of the DataFusion's SessionConfig.extensions and thread it around as part of the context, rather than manually requiring it to be stored in every struct.

@mbutrovich

Copy link
Copy Markdown
Contributor

There both seems to be eagerness to merge this, and concern about public API.

I don't love the fact that the public API of this project makes incorrect state representable.

Is it in scope for this PR, or do we need to file issues to follow up? I don't want this to get lost and am catching up on PRs on this repo.

@NoahKusaba

NoahKusaba commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

There both seems to be eagerness to merge this, and concern about public API.

I don't love the fact that the public API of this project makes incorrect state representable.

Is it in scope for this PR, or do we need to file issues to follow up? I don't want this to get lost and am catching up on PRs on this repo.

If nobody else gets to it, I'm happy to look into how we can create a more elegant interface, as I agree with Gabotechs assessment. I've been a bit tunnel-visioned on just opening publicized functions for Ballista to hook into, and it's better to get this right at the start than refactor after the fact.

@mbutrovich

mbutrovich commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

If nobody else gets to it, I'm happy to look into how we can create a more elegant interface, as I agree with Gabotechs assessment. I've been a bit tunnel-visioned on just opening publicize functions for Ballista to hook into, and it's better to get this right at the start then refactor after the fact.

I'm okay merging this as-is as long as the discussion about the API is summarized in an issue that we can track and address before cutting the first release.

@gabotechs

Copy link
Copy Markdown
Contributor

I'm fine with merging this as-is now if that helps people get unblocked, as I unfortunately do not have a better suggestion of how to structure the API. Exposing a clean API will likely will likely have a domino effect in which we'll need to change the fields of several structs in the project.

I think it's worth exploring that and getting to the right structure will likely require some hands-on experimentation, but again, don't want to block other efforts so I'm fine with merging this as-is (probably with some issue created for tracking this).

@NoahKusaba

Copy link
Copy Markdown
Contributor Author

Rethinking the design, I don't think this new_with_schema is needed

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.

4 participants