feat: route tables by declared TableType to pluggable engines - #733
Conversation
976b75f to
6307edf
Compare
c1ce84c to
9db87f5
Compare
JingsongLi
left a comment
There was a problem hiding this comment.
Detailed review found three correctness regressions and one creation-validation gap. I verified the current head locally; details are inline.
| /// routed to a table engine (see | ||
| /// [`Catalog::load_table_routing`](crate::catalog::Catalog::load_table_routing)). | ||
| pub fn requires_table_engine(&self) -> bool { | ||
| matches!(self, TableType::IcebergTable) |
There was a problem hiding this comment.
[P1] Route every table kind the Rust reader cannot serve
This classifies only Iceberg as engine-required, so register_table_engine rejects both ObjectTable and LanceTable as if the ordinary Paimon reader supported them. It does not: the Rust read stack has no Object/Lance provider and its normal scan uses Paimon snapshots (including an empty plan when none exists), while Java CatalogUtils constructs dedicated Object, Lance, and Iceberg table implementations. A real object-table or lance-table can therefore fail or silently appear empty, and callers cannot register the resolver introduced here. Please mark every currently non-native type (at least Object/Lance/Iceberg) as engine-required/fail-closed until native providers exist, and replace the Lance rejection test with routing coverage.
There was a problem hiding this comment.
@JingsongLi Good catch. Done: object-table and lance-table now require an engine too, and both catalogs fail closed.
| .resolve_table(identifier.database(), identifier.object()) | ||
| .await?; | ||
| // Read-only wrap: DML must not reach the engine provider. | ||
| Ok(resolved.map(|inner| { |
There was a problem hiding this comment.
[P1] Reject or implement time travel for routed providers
Returning this wrapper breaks the existing raw SQLContext::ctx().sql time-travel path: PaimonRelationPlanner cannot downcast it to PaimonTableProvider, returns Original, and DataFusion 54's default relation planner discards TableFactor.version. I reproduced this on this head: after selecting the Databricks dialect, SELECT * FROM ...it VERSION AS OF 999999 succeeded and returned the current two engine rows instead of rejecting the nonexistent version. The high-level SQLContext::sql path fails closed, but the public raw context is intentionally supported and tested for time travel. Please make the relation planner recognize routed providers and explicitly reject VERSION/TIMESTAMP, or extend the resolver contract to carry those selectors.
There was a problem hiding this comment.
@JingsongLi The reproduction steps helped. Sweeping that surface turned up three more ways in: session selectors, selectors stored in the table's own options, and unsupported scan options. All rejected now, and registration installs the relation planner so the rejection holds without SQLContext.
| ) -> Result<crate::catalog::RoutedTableLoad> { | ||
| let (table_path, schema) = self.fetch_table_schema(identifier).await?; | ||
| let options = CoreOptions::new(schema.options()); | ||
| let declared = options.table_type()?; |
There was a problem hiding this comment.
[P1] Keep type immutable before trusting it for routing
This makes the latest schema's type an authoritative engine switch, but TableSchema::apply_changes still accepts arbitrary SetOption/RemoveOption, and SQL exposes that through ALTER TABLE ... SET TBLPROPERTIES. A populated Paimon table can be changed to type=iceberg-table; with a resolver, later reads are redirected to a same-named engine table, and without one the existing snapshots become inaccessible. Java SchemaManager explicitly rejects semantic changes/removal of CoreOptions.TYPE, even before snapshots exist. Please enforce the same invariant before saving a schema (allowing only case-insensitive/default-equivalent no-ops) and cover filesystem plus REST ALTER paths.
There was a problem hiding this comment.
@JingsongLi Agreed, rejecting unknown values wasn't enough. Done: alter_table rejects any change or removal of type, allowing only case-insensitive no-ops, matching SchemaManager.
| /// Fails on a value this client does not know. | ||
| pub fn table_type(&self) -> crate::Result<TableType> { | ||
| match self.options.get(TABLE_TYPE_OPTION) { | ||
| Some(value) => value.parse(), |
There was a problem hiding this comment.
[P2] Validate the declared type before persisting schema-0
This validation currently first runs while loading/routing. FileSystemCatalog::create_table still creates the directory and writes schema-0 without calling table_type(), and the bundled REST server delegates to it. Thus CREATE TABLE ... WITH ('type'='iceberg-tabel') returns success but every later get_table/routing call fails with unknown table type, leaving an unusable catalog entry. Please invoke this parsing from shared create/schema validation before any metadata write and add filesystem/REST tests that assert rejection leaves no table artifacts.
There was a problem hiding this comment.
@JingsongLi Done: the parse runs in Schema::validate_final_schema, shared by create and alter.
|
[P2] Keep DataFusion engine registration out of the core Catalog contract Could we avoid I do not think simply calling Could core instead expose an unconditional classification, for example: async fn load_table(&self, id: &Identifier) -> Result<LoadedTable>;
enum LoadedTable {
Paimon(Box<Table>),
External(ExternalTableMetadata),
}The catalog would load metadata once and decide only whether the core reader can construct the table. Object/Lance/Iceberg would always return This would also remove a hidden invariant in the current public API: This boundary also matches the Java direction more closely: core dispatches from table metadata without accepting caller-registered engine types. |
@JingsongLi Good call on the boundary — engine_types is gone from core. Catalog::load_table returns LoadedTable::{Paimon, External} and classifies unconditionally, so the outcome depends only on the table's own metadata. Core owns classification and the stored-metadata checks; DataFusion owns resolver selection, session semantics, and the read-only wrapper. |
| /// | ||
| /// # Errors | ||
| /// Same as [`Catalog::get_table`]. | ||
| async fn load_table(&self, identifier: &Identifier) -> Result<LoadedTable> { |
There was a problem hiding this comment.
[P1] Classify the table in the default load_table implementation
This default is the compatibility path for existing third-party Catalog implementations, but it unconditionally wraps the Table returned by get_table as LoadedTable::Paimon. If such a catalog returns a table whose stored type is object-table, lance-table, or iceberg-table, PaimonSchemaProvider::table takes the Paimon arm and never consults the registered resolver. PaimonTableProvider has no later type guard, so reads can misread the foreign table (or silently appear empty), and INSERT can write Paimon metadata into the foreign location.
This also contradicts the PR description that the default "classifies from the constructed table" so a catalog implementing only get_table still fails closed. Please first load the table, parse CoreOptions::new(table.schema().options()).table_type()?, and call the checked LoadedTable::external constructor when requires_table_engine() is true; otherwise return the Paimon variant. The built-in overrides can remain to preserve pre-construction classification and avoid token/FileIO work. A regression test should use a catalog that deliberately does not override load_table.
Purpose
REST catalogs can declare table types a Paimon reader cannot serve via the
typetable option (e.g.type=iceberg-table). paimon-datafusion tries to read every table as Paimon, so these tables fail with unrelated errors. This PR routes them to pluggable engines instead:paimon::spec::TableTypemirrors Java'sorg.apache.paimon.TableType, andCoreOptions::table_type()returns it, so DataFusion decides from the enum rather than from type strings.Catalog::load_table. A defaulted method returningLoadedTable::{Paimon, External}, so existing implementations keep working. Classification is unconditional — the outcome depends only on the table's own metadata, never on what the caller registered. Both built-in catalogs override it to classify before construction, matching Java's shared dispatch inCatalogUtils.loadTable; the default classifies from the constructed table, so a catalog that only implementsget_tablestill fails closed.object-table,lance-tableandiceberg-tableall require an engine: Java builds a dedicated table for each, this client has none, so reading one as Paimon would misread it and writing could put Paimonsnapshots over foreign data. Both catalogs fail closed on them, covering every raw
get_tablepath. Raw metadata stays describable, sopaimon-rest-servercan still answer GetTable and let its clients route.SchemaValidationandSchemaManager.register_catalog_table_engine(&ctx, ..)installsPaimonRelationPlanner, so that holds withoutSQLContexttoo.PaimonCatalogProviderso downcast paths keep working;table_existmirrors resolution, and engine-resolved providers are wrapped read-only.EngineTableRequestis#[non_exhaustive], so a follow-up can carry a snapshot selector — with an opt-in on the resolver, so an engine that ignores it never answers from current data — without breaking existing resolvers. A follow-up also adds an Iceberg-backed resolver crate.