Skip to content

Require matching extension types in the DataFusion physical schema - #11268

Draft
connortsui20 wants to merge 1 commit into
developfrom
ct/datafusion-ext-type-match
Draft

connortsui20 wants to merge 1 commit into
developfrom
ct/datafusion-ext-type-match

Conversation

@connortsui20

@connortsui20 connortsui20 commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

Summary

The physical schema copies the extension type of each table field onto the file's column and never checks the file. So a table inferred from one Json file and one plain Utf8 file labels the plain strings as Json (against X24). #11225 made this worse, since a table field without the extension name now reads a Json or Vector column as plain storage instead of failing. So every table field, at every level, must now name the same extension type as its file column, or the scan fails.

Changes

The check compares the table field with what ArrowSession exports for the file's dtype, so it also covers the types without a plugin, which #11224 exports with their Vortex ID.

Some rules follow from where Arrow keeps the extension type. A run-end field keeps it on its values field, and a dictionary field on itself. A Null column matches any extension type, since it has no values to mislabel and Schema::try_merge merges it into any type. A missing metadata entry matches an empty one, since writers differ on whether they write an empty entry for a type without parameters. When the names match but the metadata differs, the check asks the importer whether the table field is the same type, since writers serialize some types differently (arrow.json can carry "" or {}).

Note that the opener checks every column of a file, so a mismatch in one column fails every query on that file, as a layout mismatch already does. #11270 asks whether a column that the query doesn't read should fail. Since #11225 merged on its own, develop reads such a column as plain storage until this PR lands.

API Changes

  • A DataFusion scan fails when a table field and its file column don't name the same extension type, at any level. It used to read the storage or mislabel it.
  • A dtype that can't be exported to Arrow fails at every level.
  • Run-end and dictionary values get the layout checks, e.g., REE(values: Utf8) over an i32 column fails at the schema step.
  • VortexDataSourceBuilder::build checks a caller-supplied schema.

@connortsui20 connortsui20 added the changelog/fix A bug fix label Nov 5, 2026
@connortsui20
connortsui20 force-pushed the ct/datafusion-ext-physical-schema branch from 49c0209 to 9d81df5 Compare November 5, 2026 16:43
@connortsui20
connortsui20 force-pushed the ct/datafusion-ext-type-match branch from 99ac17a to 70a18ed Compare November 5, 2026 16:43
@connortsui20
connortsui20 force-pushed the ct/datafusion-ext-type-match branch from 70a18ed to bb850b3 Compare November 5, 2026 17:33
@connortsui20
connortsui20 force-pushed the ct/datafusion-ext-physical-schema branch 2 times, most recently from 1ed554d to f65ff0d Compare November 5, 2026 18:47
Base automatically changed from ct/datafusion-ext-physical-schema to develop November 5, 2026 18:57
@connortsui20
connortsui20 force-pushed the ct/datafusion-ext-type-match branch from bb850b3 to 1266632 Compare November 5, 2026 20:45
connortsui20 added a commit that referenced this pull request Nov 5, 2026
)

## Summary

- Tracking Issue: #11114

Right now, an extension type without an Arrow plugin can't leave Vortex
through Arrow at all. That covers `FixedShapeTensor`, types from other
crates, and unknown types read from a file, so DataFusion can't even
infer the schema of a file that holds one, and the Python, JNI, and FFI
schema exports fail the same way. A plugin for every type doesn't scale,
and an unknown type can't have one. The import has the same gap, since a
field whose extension name is a Vortex ID imports as plain storage.

So a type without a plugin now exports its storage plus Arrow extension
metadata, with its Vortex ID in `ARROW:extension:name` and its metadata
in `ARROW:extension:metadata`. When the session knows the Vortex ID, the
import uses that metadata to rebuild the type, so the type survives a
round trip through Arrow, as X24 requires of every boundary.

## Changes

The metadata is base64, since Arrow field metadata must be UTF-8 and
Vortex metadata is arbitrary bytes.

An Arrow extension name that Vortex doesn't know still imports as plain
storage (X29), unlike the closed #10651, which imported it as a foreign
type. Under the opacity plan, a foreign type only supports structural
operations, so a pyarrow column with an unknown extension would lose
filters and comparisons, and a file that holds it would need
`allow_unknown` to read.

This has to land before U1, which makes a storage-like target an error.
A target whose extension name is the array's own Vortex ID still gets
the storage, as long as it has the same metadata. So a caller keeps a
lossless way to export, e.g., a timestamp column as `Int64`.

An export without a target field (`execute_arrow` with no target,
`Datum`, `ArrowSession::to_arrow_datatype`, and the Python array
exports) has no Arrow field to hold the extension metadata, so it fails
for a top-level column of these types instead of returning storage that
has lost its type. All of them build their field with
`ArrowSession::default_arrow_export_field`, so the check lives in one
place and adds no work to an export. A nested column works, because its
child field keeps the metadata. The types with a plugin still lose their
name at the top level, which #11286 tracks.

Note that until #11268, a table that mixes one of these columns with
plain storage labels the plain rows with the extension type, as it
already does for `Json`. Only the `ArrowSession` that
`vortex_arrow::initialize` registers knows the extension types of its
session, so a session created lazily imports these types as plain
storage. The tests in `vortex-arrow/tests/vortex_id.rs` pin the field
format and the round trip at the top level, in a struct, in a list, and
over a timestamp, and a DataFusion test copies a `FixedShapeTensor`
column through `COPY`.

## API Changes

- `ArrowSession::to_arrow_field` and `to_arrow_schema` export these
types instead of failing.
- The new `ArrowSession::default_arrow_export_field` returns the field
that an export without a target uses, and fails for a top-level type
without a plugin.
- When no importer accepts a field, but the session knows its Vortex ID,
the import rebuilds the type. It fails if the metadata or the storage
doesn't fit the type. For example, an `Int64` field named
`vortex.timestamp` now imports as a `Timestamp`, and a
`vortex.tensor.vector` field that the `Vector` importer declines now
fails instead of importing as plain storage.

---------

Signed-off-by: Connor Tsui <[email protected]>
The physical schema copied each table field's extension metadata onto the
file's column without checking the file's dtype. So a plain table field read a
`Json` column as plain strings, and a table inferred from `Json` and plain files
labeled the plain strings as `Json`.

Now every table field, including nested ones, must name the extension type
that `ArrowSession` exports for the file's dtype, or the scan fails:

- A missing metadata entry matches an empty one. When only the metadata
  differs, the field still matches if `ArrowSession` imports it as the file's
  dtype, so `arrow.json` with `{}` matches a `vortex.json` column.
- A dictionary field names the extension type of its values on itself, and its
  value type gets the checks of any field.
- A run-end field names the extension type of its values on its values field,
  where the Arrow import and export read it. So the values field gets the
  checks of any field, and the run-end field must name none.
- The entries field of a map has no Vortex dtype, so it must name none.
- A `Null` column matches any extension type, since it has no values to
  mislabel. The values of a run-end or dictionary field over it are not
  checked.

Errors name the path of the field, such as `col.entries.key`, and the file
opener adds the path of the file.

Other behavior changes:

- Run-end and dictionary values now get layout checks, also in plain columns.
  For example, `REE(values: Utf8)` over `i32` now fails at the schema step.
- A dtype that cannot be exported fails at every level. For example, an
  extension type that the session does not know, read from a file, fails until
  #11224 exports it.
- `VortexDataSourceBuilder::build` now checks a caller-supplied schema against
  the data source's dtype.
- A mismatch in any column of a file fails every query on that file, as a
  layout mismatch already does. The opener still computes the physical schema
  over every column of the file, and a separate issue will decide that policy.

Signed-off-by: Connor Tsui <[email protected]>
@connortsui20
connortsui20 force-pushed the ct/datafusion-ext-type-match branch from 1266632 to c6697d2 Compare November 9, 2026 18:45

This branch has not been deployed

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

Labels

changelog/fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant