Skip to content

AddFiles: CommitSchemaUnion - #40104

Open
claudevdm wants to merge 1 commit into
apache:masterfrom
claudevdm:addfiles-commit-schema-union
Open

AddFiles: CommitSchemaUnion #40104
claudevdm wants to merge 1 commit into
apache:masterfrom
claudevdm:addfiles-commit-schema-union

Conversation

@claudevdm

Copy link
Copy Markdown
Collaborator

The commit side of the schema pre-pass for an existing table. Takes one window's distinct canonical file schemas (most common first) and evolves the table once to cover them all, adding exactly one schema version per window regardless of how many distinct schemas it held.

Flow, per attempt:

  1. Load + classify: fresh table load; SchemaDelta.classify per schema. Empty deltas skip, deltas the config disallows go to the incompatible list with their reason.
  2. Fold on a scratch transaction that is never committed: per accepted schema, one unionByNameWith plus explicit relaxations for absent required columns. Two schemas can conflict with each other even when each is fine against the table (score long vs score string); that surfaces only while staging, so the offender is blamed ("conflicts with another file schema in the same window") and the fold is rebuilt without it, most common first, so the majority wins ties.
  3. Relax new fields: the union keeps the file's optionality below newly added columns, so one lucky file would impose required fields on everyone. Every field new in this window becomes optional, at every level including under lists and maps; only map-key subtrees are kept (keys are required by definition). Pins deliberately do not shape new columns,they keep existing required columns from being relaxed (enforced in classify) and gate files at registration.
  4. Replay + commit: a single unionByNameWith(foldedSchema) on the real transaction reproduces the fold's net effect (a checkState asserts they agree), the name mapping is regenerated if absent/stale (zero-copy files are unreadable without it, reason enough to commit alone), then one commit. Under FAIL_PIPELINE, incompatible schemas throw before anything commits; under ROUTE_TO_ERRORS they're logged and their files fail the per-file check later with the same reason. Nothing commits when nothing changed.

Retry: CommitFailedException restarts from the load, up to 5 attempts, thrown by the committer on a concurrent commit, and by a guard on every transaction creation (Iceberg refreshes per newTransaction(), so mid-attempt concurrent schema changes are detected as drift from the classified snapshot and retried rather than failed). Classification is redone against the new state, not replayed.

Please add a meaningful description for your change here


Thank you for your contribution! Follow this checklist to help us incorporate your contribution quickly and easily:

  • Mention the appropriate issue in your description (for example: addresses #123), if applicable. This will automatically add a link to the pull request in the issue. If you would like the issue to automatically close on merging the pull request, comment fixes #<ISSUE NUMBER> instead.
  • Update CHANGES.md with noteworthy changes.
  • If this contribution is large, please file an Apache Individual Contributor License Agreement.

See the Contributor Guide for more tips on how to make review process smoother.

To check the build health, please visit https://github.com/apache/beam/blob/master/.test-infra/BUILD_STATUS.md

GitHub Actions Tests Status (on master branch)

Build python source distribution and wheels
Python tests
Java tests
Go tests

See CI.md for more information about GitHub Actions CI or the workflows README to see a list of phrases to trigger workflows.

…ble in one transaction

The commit side of the pre-pass, for an existing table (creation comes
next). commit(catalog, tableId, schemas, config, handling, committer)
takes the distinct canonical file schemas of one window, most common
first, and returns the table's schema id afterwards.

One attempt, in order:

1. Fresh load of the table; the classification below must run against
   the current schema, never a cached one.
2. SchemaDelta.classify for each schema. Empty deltas are skipped;
   deltas the config does not allow go to the incompatible list with
   their reason; the rest are accepted.
3. Staging, most common first, on one Transaction: per accepted schema
   one unionByNameWith plus an explicit makeColumnOptional for every
   path the delta reported as an absent required column. Two accepted
   schemas can still conflict with each other (file A says score is
   long, file B says string; each is fine against the table alone).
   That only surfaces while staging and poisons the transaction, so the
   offender is moved to the incompatible list ("conflicts with another
   file schema in the same window") and the transaction is rebuilt
   without it. Most common first means the majority wins such a tie.
4. relaxNewRequiredFields: the union adds top-level columns as optional
   but keeps the file's optionality for fields inside a struct it just
   added, so one file's luck would impose required nested columns on
   every future file. Every field new in this transaction is made
   optional at every level, except pinned paths and fields under lists
   and maps (which the union already handles). Found by fuzzing;
   testFieldsInsideAnAddedStructAreOptional covers it.
5. Name mapping repair: if the table's schema.name-mapping.default is
   absent, malformed or does not cover the staged schema, it is
   regenerated (NameMappingUtils.regenerate, preserving custom names by
   field id). Zero-copy files carry no field ids, so a column missing
   from the mapping is unreadable in every registered file; a missing
   mapping alone is reason enough to commit even when the schema did
   not change.
6. Incompatible schemas are reported: under FAIL_PIPELINE an
   IncompatibleSchemaException listing every one (schema, file count,
   reason) is thrown before anything is committed; under
   ROUTE_TO_ERRORS they are logged and the rest proceeds (their files
   fail the per-file coverage check later, with the same reason).
7. Single commit through the injectable Committer, then refresh.
   Nothing is committed when nothing was staged.

Retry: CommitFailedException (a concurrent writer moved the table
between load and commit) restarts the whole attempt from step 1, up to
MAX_ATTEMPTS (5); classification is redone against the new state rather
than replayed, since the concurrent change may have made a delta empty
or incompatible. A persistent failure propagates.

Worked example: table {id required long, name required string}; window
schemas A x5 {id, name optional, score long}, B x1 {id, score string}
under all options. A: relax name, add score. B: absent name
(relaxation), score conflicts with A's addition while staging, so B is
incompatible. FAIL_PIPELINE: exception, table untouched. ROUTE_TO_ERRORS:
one commit with name optional and score optional long; B's file will be
routed to errors at registration.

The tests use a local HadoopCatalog and check metadata versions to
prove "nothing committed" claims, and inject a Committer that fails
once to exercise the retry path.
@github-actions github-actions Bot added java io iceberg Iceberg IO connector labels Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Assigning reviewers:

R: @chamikaramj for label java.
R: @chamikaramj for label iceberg.

Note: If you would like to opt out of this review, comment assign to next reviewer.

Available commands:

  • stop reviewer notifications - opt out of the automated review tooling
  • remind me after tests pass - tag the comment author after tests pass
  • waiting on author - shift the attention set back to the author (any comment or push by the author will return the attention set to the reviewers)

The PR bot will only process comments in the main thread (not review comments).

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant