Skip to content

fix(schema): enforce executable type evolution - #681

Open
QuakeWang wants to merge 2 commits into
apache:mainfrom
QuakeWang:alter-type-verify
Open

fix(schema): enforce executable type evolution#681
QuakeWang wants to merge 2 commits into
apache:mainfrom
QuakeWang:alter-type-verify

Conversation

@QuakeWang

Copy link
Copy Markdown
Member

Purpose

ALTER COLUMN TYPE previously accepted Arrow-castable type pairs without guaranteeing Paimon-compatible conversion of historical files. Predicates, statistics, and global indexes could therefore use incompatible physical-type semantics.

Brief change log

  • Add explicit Paimon-compatible type-evolution executors and historical-schema preflight validation.
  • Cast old files to the current logical schema and fail open for unsafe predicate or statistics pruning.
  • Normalize bounded character and binary writes after type evolution.
  • Record global-index build schema IDs and ignore schema-incompatible index entries.
  • Apply the validation consistently to filesystem, REST catalog, and DataFusion paths.

Tests

  • Workspace format, build, and clippy checks
  • Paimon and REST server all-target tests
  • DataFusion integration tests with Spark fixtures
  • Vortex end-to-end test

API and Format

Adds an optional build_schema_id to global-index metadata. Legacy index entries remain readable.

Documentation

Documents the supported type-evolution matrix and safety restrictions.

ALTER TYPE admitted Arrow-castable pairs without guaranteeing Paimon-compatible reads of historical files. Predicate pruning and global indexes could also retain stale physical-type semantics.

Add explicit evolution executors and history preflight checks, apply safe residual filtering and writer normalization, and fence schema-incompatible global index entries across catalog and DataFusion paths.

Signed-off-by: QuakeWang <wangfuzheng0814@foxmail.com>
{"name": "_INDEX_META", "type": ["null", "bytes"], "default": null},
{"name": "_SOURCE_META", "type": ["null", "bytes"], "default": null}
{"name": "_SOURCE_META", "type": ["null", "bytes"], "default": null},
{"name": "_BUILD_SCHEMA_ID", "type": ["null", "long"], "default": null}

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.

What is it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It records the table schema ID used to build a global-index entry. It closes a race with ALTER COLUMN TYPE: schema evolution does not advance the data snapshot, so an index built with the old field type may still pass the latest-snapshot commit guard. Readers compare the indexed field types from this schema with the current schema and ignore incompatible entries. Legacy and Java entries leave it null and are handled conservatively through schema history.

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.

After reviewing the mixed-version write path, I prefer option (1) for this PR unless coordinated Java support is shipped first. Moving the field to a trailing top-level position lets the old Rust decoder skip it, but an old Java IndexManifestFileHandler still deserializes entries into its six-field model and rewrites the whole combined manifest, so it drops either a nested or top-level structured field. That loses provenance and leaves new Rust unable to use or rebuild the index safely after evolution. A future compatible design should either gate writers on a Java version that preserves the field or place the schema ID in an opaque payload old writers round-trip unchanged.

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

The addition of _BUILD_SCHEMA_ID breaks compatibility with older Rust readers. Testing has confirmed that the new writer produces two Index Manifests, causing the current main reader to report unknown FileKind: 7.

@QuakeWang

QuakeWang commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

@JingsongLi Thanks, confirmed. The nested _BUILD_SCHEMA_ID extension is not backward compatible with the current Rust fast decoder. I see two safe options:
(1) remove build-schema provenance from this PR and use the conservative schema-history fallback, which preserves the current Java format but cannot distinguish indexes rebuilt after a type change from legacy indexes; or (2) store it as a trailing nullable top-level IndexManifestEntry field, which the current main reader already skips while retaining the ALTER/index-commit race guard. Would you prefer removing the field from this PR, or is the top-level optional extension acceptable?

let result: ArrayRef = match timestamp.precision() {
0..=3 => Arc::new(
downcast_primitive::<TimestampMillisecondType>(array, "TIMESTAMP")?
.unary::<_, Date32Type>(|value| (value / 86_400_000) as i32),

@JingsongLi JingsongLi Aug 20, 2026

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.

[P1] Use floor division for pre-epoch timestamps

Rust integer division truncates toward zero, but Paimon Java TimestampToDateCastRule uses Math.floorDiv. Consequently -1 ms becomes epoch day 0 instead of -1, and -86_400_001 ms becomes -1 instead of -2, silently shifting pre-1970 non-midnight values and residual-filter results by one day. Please use div_euclid in the millisecond, microsecond, and nanosecond branches and change the new test expectation to [-1, -1, -2, null]; the current test passes only because it codifies the wrong truncation.

table.schema(),
&new_schema,
)
.await?;

@JingsongLi JingsongLi Aug 20, 2026

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.

[P1] Fence this preflight with the schema version

The validation is based on a prior get_table, while AlterTableRequest carries only changes and no expected schema ID. For example, this client can validate INT -> BIGINT, a concurrent Java client can commit INT -> DECIMAL and write a Decimal file, and then this stale POST is applied by the server as DECIMAL -> BIGINT (supported by Java). The final history now requires DECIMAL -> BIGINT, which this PR's Rust executor does not implement, so Rust fails to read the intermediate file despite the preflight succeeding. The preflight must run atomically on the server, or the request must assert the schema ID and retry validation on conflict.

}
}
Some((col, source_type)) => columns.push(
cast_array_for_schema_evolution(

@JingsongLi JingsongLi Aug 20, 2026

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.

[P1] Keep in-flight readers able to read post-ALTER files

This replacement of the generic Arrow cast assumes the table schema is always newer than the file schema, but long-lived providers keep a fixed Table while their scans plan the latest snapshot. A provider registered under INT can therefore see a new DECIMAL file after another client performs INT -> DECIMAL; here the source is Decimal and the target is the provider's Int schema, and the new one-way executor rejects DECIMAL -> INT even though the previous Arrow cast handled it. This breaks running readers/jobs across ALTER. Please either implement the reverse executors required by old-schema readers or fence/refresh providers so a fixed schema cannot scan newer file schemas.

.apply_changes(changes)
.map_err(|e| fill_table_name(e, identifier))?;
let new_schema = apply_schema_changes(&current, &changes, identifier)?;
validate_type_evolution_precommit(&self.file_io, &table_path, &current, &new_schema)

@JingsongLi JingsongLi Aug 20, 2026

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.

[P1] Include the schema version in the index-build commit guard

This preflight and schema save are not coordinated with global-index commits, and an ALTER does not advance the snapshot ID. An index build planned at snapshot S/schema N can therefore commit after this saves schema N+1 because commit_if_latest_snapshot still sees S; the commit stamps the index with N and also creates a new snapshot whose schema_id is the stale N. Fresh Rust drops the index, time travel/branch creation observes the wrong schema, and an old Java reader has no build-schema filter and may use the stale index. Please atomically guard index commits on both snapshot and schema ID (or serialize them with ALTER), and stamp snapshots from the latest schema as Java does.

{"name": "_INDEX_META", "type": ["null", "bytes"], "default": null},
{"name": "_SOURCE_META", "type": ["null", "bytes"], "default": null}
{"name": "_SOURCE_META", "type": ["null", "bytes"], "default": null},
{"name": "_BUILD_SCHEMA_ID", "type": ["null", "long"], "default": null}

@JingsongLi JingsongLi Aug 20, 2026

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.

[P2] Do not let old Java rewrites erase this marker

Current Java GlobalIndexMeta has six fields, and IndexManifestFileHandler reads all active entries and serializes a new combined manifest. An old Java writer therefore drops this seventh field on any index/DV manifest rewrite. Back on new Rust, a valid index built after type evolution is treated as legacy/incompatible and filtered out, while final overlap validation still retains the physical entry and blocks rebuilding it until an explicit drop. A trailing top-level field fixes the old-Rust decode issue but is still lost by an old serializer. Please gate emission on a Java version that preserves the marker, or encode the provenance in an opaque payload old writers round-trip unchanged.

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.

2 participants