This is an automated email from the ASF dual-hosted git repository.
claudevdm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new 2bd256532b5 AddFiles: create the table from the file schemas when it
does not exist (#40124)
2bd256532b5 is described below
commit 2bd256532b55f82196d3524c56ac1e2a42647da1
Author: claudevdm <[email protected]>
AuthorDate: Wed Sep 16 12:01:40 2026 -0400
AddFiles: create the table from the file schemas when it does not exist
(#40124)
* AddFiles: create the table from the file schemas when it does not exist
Before this change the pre-pass required an existing table. AddFiles
without evolution creates a missing table from whichever file happens to
reach ConvertToDataFile first; with the pre-pass we can do better, since
every schema of the window is known before anything is created.
Create path (CommitSchemaUnion.create), taken when loadTable throws
NoSuchTableException:
- The window's schemas are folded into one union on a scratch create
transaction that is never committed (the same staging loop as the
evolve path, so a schema conflicting with another is reported or
skipped per the handling, and the create is abandoned under
FAIL_PIPELINE before anything exists).
- The real table is built directly from createdSchema(union, config):
every column optional at every level - one lucky file's declared or
proven required-ness must not become a table constraint that the next
file violates - except pinned paths and their ancestors, which are
created required. Creation is the schema-authoring moment; evolution
never tightens columns afterwards. A pin that no file schema carries
fails the create under FAIL_PIPELINE (later windows could only add
the column optional, so the pin would stay inert forever) and warns
under ROUTE_TO_ERRORS. The created table is born with a single schema
version.
- Creation is not gated on any particular evolution option: the options
guard an existing table's schema, and there is none to guard yet.
- Partition spec, sort order and table properties come from
TableCreation (the AddFiles constructor arguments), resolved against
the union, so a partition or sort column carried by any schema of the
window works; the name mapping is written in the same transaction.
- Create race: two workers (or a concurrent non-evolution AddFiles) may
create the table at once. AlreadyExistsException joins
CommitFailedException in the retry, and the next attempt takes the
evolve path against the table the other party created.
- Empty window against a missing table returns NO_TABLE (-1) and
creates nothing.
* comments
* test
---
.../beam/sdk/io/iceberg/CommitSchemaOnce.java | 89 ++++
.../beam/sdk/io/iceberg/CommitSchemaUnion.java | 513 +++++++++++++++-----
.../java/org/apache/beam/sdk/io/iceberg/Pins.java | 5 +
.../beam/sdk/io/iceberg/CommitSchemaOnceTest.java | 157 +++++++
.../beam/sdk/io/iceberg/CommitSchemaUnionTest.java | 516 +++++++++++++++++++++
5 files changed, 1169 insertions(+), 111 deletions(-)
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/CommitSchemaOnce.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/CommitSchemaOnce.java
new file mode 100644
index 00000000000..6fd5171c2ea
--- /dev/null
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/CommitSchemaOnce.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.iceberg;
+
+import static org.apache.beam.sdk.metrics.Metrics.counter;
+
+import java.util.List;
+import
org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.IncompatibleSchemaHandling;
+import org.apache.beam.sdk.metrics.Counter;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/**
+ * Commits one schema union per window (the combine output is one element per
window) and emits the
+ * resulting schema id as the signal for the Wait.on gate ahead of file
registration.
+ */
+class CommitSchemaOnce extends DoFn<List<CollectDistinctSchemas.SchemaGroup>,
Long> {
+ static final String COMMITS_COUNTER = "numSchemaCommits";
+ private static final Counter numSchemaCommits =
counter(CommitSchemaOnce.class, COMMITS_COUNTER);
+
+ private final IcebergCatalogConfig catalogConfig;
+ private final String identifier;
+ private final SchemaEvolutionConfig config;
+ private final IncompatibleSchemaHandling handling;
+ private final CommitSchemaUnion.TableCreation creation;
+ private final CommitSchemaUnion.Committer committer;
+ private transient @MonotonicNonNull Catalog catalog;
+
+ CommitSchemaOnce(
+ IcebergCatalogConfig catalogConfig,
+ String identifier,
+ SchemaEvolutionConfig config,
+ IncompatibleSchemaHandling handling,
+ CommitSchemaUnion.TableCreation creation) {
+ this(
+ catalogConfig, identifier, config, handling, creation,
CommitSchemaUnion.DEFAULT_COMMITTER);
+ }
+
+ CommitSchemaOnce(
+ IcebergCatalogConfig catalogConfig,
+ String identifier,
+ SchemaEvolutionConfig config,
+ IncompatibleSchemaHandling handling,
+ CommitSchemaUnion.TableCreation creation,
+ CommitSchemaUnion.Committer committer) {
+ this.catalogConfig = catalogConfig;
+ this.identifier = identifier;
+ this.config = config;
+ this.handling = handling;
+ this.creation = creation;
+ this.committer = committer;
+ }
+
+ @ProcessElement
+ public void process(
+ @Element List<CollectDistinctSchemas.SchemaGroup> schemas,
OutputReceiver<Long> out) {
+ if (catalog == null) {
+ catalog = catalogConfig.catalog();
+ }
+ TableIdentifier tableId = IcebergUtils.parseTableIdentifier(identifier);
+ // The committer runs only when a transaction is actually committed, so
wrapping it counts
+ // real commits and skips no-op windows.
+ CommitSchemaUnion.Committer counting =
+ txn -> {
+ committer.commit(txn);
+ numSchemaCommits.inc();
+ };
+ long schemaId =
+ CommitSchemaUnion.commit(catalog, tableId, schemas, config, handling,
creation, counting);
+ out.output(schemaId);
+ }
+}
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnion.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnion.java
index 8ffc4780f6e..1e145a122c8 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnion.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnion.java
@@ -21,7 +21,10 @@ import static
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Pr
import java.io.Serializable;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import
org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.IncompatibleSchemaHandling;
import org.apache.beam.sdk.util.BackOff;
@@ -36,7 +39,9 @@ import org.apache.iceberg.Transaction;
import org.apache.iceberg.UpdateSchema;
import org.apache.iceberg.catalog.Catalog;
import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.AlreadyExistsException;
import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.exceptions.NoSuchTableException;
import org.apache.iceberg.exceptions.ValidationException;
import org.apache.iceberg.mapping.NameMapping;
import org.apache.iceberg.types.Type;
@@ -48,12 +53,17 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * Applies the distinct file schemas of a window to the table in one
transaction: fresh load,
- * classify each schema most common first, fold the allowed unions (plus
explicit relaxations for
- * required columns absent from files) on a scratch transaction, stage the
folded result as one
- * schema update, repair the name mapping, commit once. The fold keeps
per-schema blame for
- * cross-schema conflicts while the table gains a single schema version per
window; the scratch
- * transaction is never committed. Nothing is committed when nothing changes.
+ * Applies the distinct file schemas of a window to the table in one commit,
in phases named by the
+ * methods of this class: {@code classify} each schema against a fresh load of
the table (most
+ * common first), {@code fold} the accepted ones into a single union on
scratch transactions that
+ * are never committed, {@code replay} the folded result onto the real
transaction as one schema
+ * update, repair the name mapping, commit once. The fold keeps per-schema
blame for cross-schema
+ * conflicts while the table gains a single schema version per window. Nothing
is committed when
+ * nothing changes.
+ *
+ * <p>When the table does not exist, {@code create} builds it instead: {@code
foldForCreate}
+ * computes the same union, and the table is born from it directly with pinned
columns and their
+ * ancestors required.
*
* <p>Incompatible schemas either fail the whole call before any commit ({@link
* IncompatibleSchemaHandling#FAIL_PIPELINE}) or are skipped so their files
reach the error output
@@ -64,6 +74,25 @@ final class CommitSchemaUnion {
static final int MAX_ATTEMPTS = 5;
+ /** Returned when the table does not exist and there is no schema to create
it from. */
+ static final long NO_TABLE = -1L;
+
+ /** How to create the table when it does not exist: from the union of the
window's schemas. */
+ static final class TableCreation implements Serializable {
+ final @Nullable List<String> partitionFields;
+ final @Nullable List<String> sortFields;
+ final @Nullable Map<String, String> properties;
+
+ TableCreation(
+ @Nullable List<String> partitionFields,
+ @Nullable List<String> sortFields,
+ @Nullable Map<String, String> properties) {
+ this.partitionFields = partitionFields;
+ this.sortFields = sortFields;
+ this.properties = properties;
+ }
+ }
+
/** Injectable so tests can exercise the commit retry path. */
interface Committer extends Serializable {
void commit(Transaction txn);
@@ -78,6 +107,22 @@ final class CommitSchemaUnion {
}
}
+ private static final class Accepted {
+ final Schema schema;
+ final String json;
+ final long files;
+
+ /** Null on the create path: the seed table is empty, so there is nothing
to relax. */
+ final @Nullable SchemaDelta delta;
+
+ Accepted(Schema schema, String json, long files, @Nullable SchemaDelta
delta) {
+ this.schema = schema;
+ this.json = json;
+ this.files = files;
+ this.delta = delta;
+ }
+ }
+
private static final class Incompatible {
final String schemaJson;
final long files;
@@ -111,7 +156,8 @@ final class CommitSchemaUnion {
private CommitSchemaUnion() {}
/**
- * Applies the schemas and returns the table's schema id after the call.
+ * Applies the schemas and returns the table's schema id after the call, or
{@link #NO_TABLE} when
+ * the table is missing and there is no schema to create it from.
*
* @param schemas the window's distinct schema groups, most common first
*/
@@ -121,6 +167,7 @@ final class CommitSchemaUnion {
List<CollectDistinctSchemas.SchemaGroup> schemas,
SchemaEvolutionConfig config,
IncompatibleSchemaHandling handling,
+ TableCreation creation,
Committer committer) {
// The catalog is already under contention when a retry fires; back off
(jittered by
// FluentBackoff) instead of piling on. Iceberg's own metadata retries
(commit.retry.*)
@@ -133,8 +180,9 @@ final class CommitSchemaUnion {
.backoff();
for (int attempt = 1; ; attempt++) {
try {
- return commitOnce(catalog, tableId, schemas, config, handling,
committer);
- } catch (CommitFailedException e) {
+ return commitOnce(catalog, tableId, schemas, config, handling,
creation, committer);
+ } catch (CommitFailedException | AlreadyExistsException e) {
+ // a concurrent commit, or a create race: the next attempt loads the
fresh state
try {
if (!BackOffUtils.next(Sleeper.DEFAULT, backoff)) {
throw e;
@@ -159,78 +207,31 @@ final class CommitSchemaUnion {
List<CollectDistinctSchemas.SchemaGroup> schemas,
SchemaEvolutionConfig config,
IncompatibleSchemaHandling handling,
+ TableCreation creation,
Committer committer) {
- Table table = catalog.loadTable(tableId);
+ Table table;
+ try {
+ table = catalog.loadTable(tableId);
+ } catch (NoSuchTableException e) {
+ return create(catalog, tableId, schemas, config, handling, creation,
committer);
+ }
// Every transaction below must share this snapshot: classification, the
fold and the replay
// all reason about the same table state (newTransactionOn enforces it).
Schema base = table.schema();
- List<Incompatible> incompatible = new ArrayList<>();
- List<Accepted> accepted = new ArrayList<>();
- for (CollectDistinctSchemas.SchemaGroup group : schemas) {
- Schema fileSchema =
- FileSchemas.markRequired(
- SchemaParser.fromJson(group.getSchemaJson()),
group.getNullFreeColumns());
- SchemaDelta delta = SchemaDelta.classify(table, fileSchema);
- if (delta.isEmpty()) {
- continue;
- }
- if (!delta.allowedBy(config)) {
- incompatible.add(
- new Incompatible(
- group.getSchemaJson(), group.getFiles(),
delta.disallowedReason(config)));
- continue;
- }
- accepted.add(new Accepted(fileSchema, group.getSchemaJson(),
group.getFiles(), delta));
- }
- Transaction scratch = stageAll(table, base, tableId, accepted,
incompatible);
- boolean folded = !accepted.isEmpty();
- if (folded) {
- relaxNewRequiredFields(scratch, base);
- }
+ List<Incompatible> incompatible = new ArrayList<>();
+ List<Accepted> accepted = classify(table, schemas, config, incompatible);
+ Schema merged = fold(table, base, tableId, accepted, incompatible);
Transaction txn = newTransactionOn(table, base, tableId);
- if (folded) {
- Schema merged = scratch.table().schema();
- // One union replays the fold's net effect (additions, promotions,
relaxations) so the
- // table gains a single schema version instead of one per folded schema.
- txn.updateSchema().unionByNameWith(merged).commit();
- // toString of the args runs only on failure
- Schema foldResult = TypeUtil.assignIncreasingFreshIds(merged);
- Schema replayResult =
TypeUtil.assignIncreasingFreshIds(txn.table().schema());
- checkState(
- replayResult.sameSchema(foldResult),
- "replaying the folded schema union for %s diverged from the fold;
fold: %s replay: %s",
- tableId,
- foldResult,
- replayResult);
+ if (merged != null) {
+ replay(txn, merged, tableId);
}
- boolean staged = folded;
+ boolean staged = merged != null;
staged |= stageNameMapping(txn);
if (!incompatible.isEmpty()) {
- long files = 0;
- for (Incompatible item : incompatible) {
- files += item.files;
- }
- if (handling == IncompatibleSchemaHandling.FAIL_PIPELINE) {
- throw new IncompatibleSchemaException(
- "Incompatible schemas for "
- + tableId
- + " ("
- + incompatible.size()
- + " schema(s), "
- + files
- + " file(s)); no schema change was committed:\n "
- + joinLines(incompatible));
- }
- LOG.warn(
- "Skipping {} incompatible schema(s) ({} file(s)) for {}; their files
will be routed to"
- + " the error output:\n {}",
- incompatible.size(),
- files,
- tableId,
- joinLines(incompatible));
+ reportIncompatible(tableId, incompatible, handling, "no schema change
was committed");
}
if (!staged) {
@@ -241,7 +242,7 @@ final class CommitSchemaUnion {
return table.schema().schemaId();
}
committer.commit(txn);
- table.refresh();
+ long schemaId = txn.table().schema().schemaId();
long acceptedFiles = 0;
for (Accepted item : accepted) {
acceptedFiles += item.files;
@@ -251,63 +252,351 @@ final class CommitSchemaUnion {
tableId,
accepted.size(),
acceptedFiles,
- table.schema().schemaId());
- return table.schema().schemaId();
+ schemaId);
+ return schemaId;
}
- private static final class Accepted {
- final Schema schema;
- final String json;
- final long files;
- final SchemaDelta delta;
-
- Accepted(Schema schema, String json, long files, SchemaDelta delta) {
- this.schema = schema;
- this.json = json;
- this.files = files;
- this.delta = delta;
+ /**
+ * Sorts the window's schemas into the ones the table must change for
(accepted) and the ones it
+ * must not ({@code incompatible}, with the reason); schemas the table
already covers drop out.
+ */
+ private static List<Accepted> classify(
+ Table table,
+ List<CollectDistinctSchemas.SchemaGroup> schemas,
+ SchemaEvolutionConfig config,
+ List<Incompatible> incompatible) {
+ List<Accepted> accepted = new ArrayList<>();
+ for (CollectDistinctSchemas.SchemaGroup group : schemas) {
+ Schema fileSchema =
+ FileSchemas.markRequired(
+ SchemaParser.fromJson(group.getSchemaJson()),
group.getNullFreeColumns());
+ SchemaDelta delta = SchemaDelta.classify(table, fileSchema);
+ if (delta.isEmpty()) {
+ continue;
+ }
+ if (!delta.allowedBy(config)) {
+ incompatible.add(
+ new Incompatible(
+ group.getSchemaJson(), group.getFiles(),
delta.disallowedReason(config)));
+ continue;
+ }
+ accepted.add(new Accepted(fileSchema, group.getSchemaJson(),
group.getFiles(), delta));
}
+ return accepted;
}
/**
- * Folds one union per accepted schema into a scratch transaction the caller
must never commit;
- * its intermediate schema versions exist only in memory. A schema can
conflict with another
- * schema's additions, which only surfaces while staging and poisons the
transaction, so on a
- * conflict the offender moves to {@code incompatible} and the transaction
is rebuilt without it.
+ * Unions the accepted schemas into the table schema on scratch transactions
that are never
+ * committed, relaxing every field the window adds; a schema that conflicts
with another only
+ * surfaces here, moves to {@code incompatible} and the fold restarts
without it. Returns the
+ * folded schema, or null when nothing needs to change.
*/
- private static Transaction stageAll(
+ private static @Nullable Schema fold(
Table table,
Schema base,
TableIdentifier tableId,
List<Accepted> accepted,
List<Incompatible> incompatible) {
while (true) {
- Transaction txn = newTransactionOn(table, base, tableId);
- Accepted failed = null;
- for (Accepted item : accepted) {
- // Both caught types carry staging conflicts: ValidationException from
Schema
- // construction at apply ("multiple fields for name"),
IllegalArgumentException from
- // SchemaUpdate preconditions ("Cannot change column type").
- try {
- stage(txn, item);
- } catch (ValidationException | IllegalArgumentException e) {
- failed = item;
- incompatible.add(
- new Incompatible(
- item.json,
- item.files,
- "conflicts with another file schema in the same window: "
- + AddFiles.errorMessage(e)));
- break;
- }
+ Transaction scratch = newTransactionOn(table, base, tableId);
+ Accepted failed = stageAll(scratch, accepted, incompatible);
+ if (failed != null) {
+ accepted.remove(failed);
+ continue;
+ }
+ if (accepted.isEmpty()) {
+ return null;
+ }
+ relaxNewRequiredFields(scratch, base);
+ return scratch.table().schema();
+ }
+ }
+
+ /**
+ * One union replays the fold's net effect (additions, promotions,
relaxations) so the table gains
+ * a single schema version instead of one per folded schema. The checkState
is a pure bug
+ * detector: concurrent changes are caught earlier, by newTransactionOn.
+ */
+ private static void replay(Transaction txn, Schema merged, TableIdentifier
tableId) {
+ txn.updateSchema().unionByNameWith(merged).commit();
+ // toString of the args runs only on failure
+ Schema foldResult = TypeUtil.assignIncreasingFreshIds(merged);
+ Schema replayResult =
TypeUtil.assignIncreasingFreshIds(txn.table().schema());
+ checkState(
+ replayResult.sameSchema(foldResult),
+ "replaying the folded schema union for %s diverged from the fold;
fold: %s replay: %s",
+ tableId,
+ foldResult,
+ replayResult);
+ }
+
+ /**
+ * Creates the table from the union of the window's schemas, with every
column optional at every
+ * level so that one lucky file cannot impose required columns on the table
- except pinned
+ * columns and their ancestors, which are created required. Columns come out
in the read side's
+ * canonical order (sorted by name at every level), not in any file's
declared order; later unions
+ * append after them.
+ */
+ private static long create(
+ Catalog catalog,
+ TableIdentifier tableId,
+ List<CollectDistinctSchemas.SchemaGroup> schemas,
+ SchemaEvolutionConfig config,
+ IncompatibleSchemaHandling handling,
+ TableCreation creation,
+ Committer committer) {
+ if (schemas.isEmpty()) {
+ LOG.info("Table {} does not exist and no file schema was read; not
creating it", tableId);
+ return NO_TABLE;
+ }
+ List<Incompatible> incompatible = new ArrayList<>();
+ @Nullable Schema merged = foldForCreate(catalog, tableId, schemas,
incompatible);
+ if (!incompatible.isEmpty()) {
+ reportIncompatible(tableId, incompatible, handling, "no table was
created");
+ }
+ if (merged == null) {
+ LOG.info("Table {} does not exist and no file schema can seed it; not
creating it", tableId);
+ return NO_TABLE;
+ }
+ // The real table is built from the folded result directly.
+ Schema created = createdSchema(merged, config);
+ reportUnenforceablePins(tableId, created, config, handling);
+ Map<String, String> properties =
+ creation.properties == null ? new HashMap<>() : new
HashMap<>(creation.properties);
+ Transaction txn =
+ catalog
+ .buildTable(tableId, created)
+
.withPartitionSpec(PartitionUtils.toPartitionSpec(creation.partitionFields,
created))
+ .withSortOrder(SortOrderUtils.toSortOrder(creation.sortFields,
created))
+ .withProperties(properties)
+ .createTransaction();
+ stageNameMapping(txn);
+ committer.commit(txn);
+ long schemaId = txn.table().schema().schemaId();
+ LOG.info(
+ "Created table {} from {} file schema(s), schema id {}",
+ tableId,
+ schemas.size() - incompatible.size(),
+ schemaId);
+ return schemaId;
+ }
+
+ /**
+ * Unions the window's schemas into one on scratch create transactions that
are never committed,
+ * seeded by the most common schema; conflicts move to {@code incompatible}
and the fold restarts
+ * without the offender.
+ */
+ private static @Nullable Schema foldForCreate(
+ Catalog catalog,
+ TableIdentifier tableId,
+ List<CollectDistinctSchemas.SchemaGroup> schemas,
+ List<Incompatible> incompatible) {
+ // The evolve path refuses names no table can absorb (dotted, empty,
differing only in case
+ // within one file) as conflicts in classify; a table must not be born
with them either.
+ List<Accepted> valid = new ArrayList<>();
+ for (CollectDistinctSchemas.SchemaGroup group : schemas) {
+ Schema fileSchema = SchemaParser.fromJson(group.getSchemaJson());
+ List<SchemaChange> invalidNames = new ArrayList<>();
+ ColumnNameChecks.findInvalidNames(fileSchema.asStruct(), "",
invalidNames);
+ if (!invalidNames.isEmpty()) {
+ incompatible.add(
+ new Incompatible(
+ group.getSchemaJson(),
+ group.getFiles(),
+ "file schema has column names no table can hold: " +
describe(invalidNames)));
+ continue;
}
+ valid.add(new Accepted(fileSchema, group.getSchemaJson(),
group.getFiles(), null));
+ }
+ if (valid.isEmpty()) {
+ return null;
+ }
+ Schema seed = valid.get(0).schema;
+ List<Accepted> rest = new ArrayList<>(valid.subList(1, valid.size()));
+ while (true) {
+ Transaction scratch = catalog.buildTable(tableId,
seed).createTransaction();
+ Accepted failed = stageAll(scratch, rest, incompatible);
if (failed == null) {
- return txn;
+ return scratch.table().schema();
}
- accepted.remove(failed);
+ rest.remove(failed);
}
}
+ /**
+ * A pin the created schema did not end up enforcing - the column appears in
no file schema, or
+ * the configured spelling resolves to a field the pin walk did not reach (a
short container
+ * spelling like a.b for a.element.b, or a path inside a map key) - would
stay inert forever,
+ * since later windows only add columns optional: a config error under
FAIL_PIPELINE, a warning
+ * under ROUTE_TO_ERRORS (streaming may see the column later).
+ */
+ private static void reportUnenforceablePins(
+ TableIdentifier tableId,
+ Schema created,
+ SchemaEvolutionConfig config,
+ IncompatibleSchemaHandling handling) {
+ List<String> unenforceable = new ArrayList<>();
+ for (String pin : config.getRequiredColumns()) {
+ Types.NestedField field = created.findField(pin);
+ if (field == null || field.isOptional()) {
+ unenforceable.add(pin);
+ }
+ }
+ if (unenforceable.isEmpty()) {
+ return;
+ }
+ Collections.sort(unenforceable);
+ if (handling == IncompatibleSchemaHandling.FAIL_PIPELINE) {
+ throw new IncompatibleSchemaException(
+ "Pinned column(s) "
+ + unenforceable
+ + " appear in none of the file schemas creating "
+ + tableId
+ + ", or their spelling does not match the column path; the
created table cannot"
+ + " make them required");
+ }
+ LOG.warn(
+ "Pinned column(s) {} appear in none of the file schemas creating {},
or their spelling"
+ + " does not match the column path; the created table cannot make
them required",
+ unenforceable,
+ tableId);
+ }
+
+ /**
+ * The created schema: every field optional at every level, list elements
and map values included,
+ * except pinned paths and their ancestors, which stay required so the
schema advertises the
+ * guarantee the per-file pin check enforces (a null ancestor nulls the
pinned leaf). Map key
+ * subtrees keep their declared shape (keys are required by definition; pins
inside them are not
+ * honored). Nothing depends on a created table's schema yet, so this is the
schema-authoring
+ * moment; evolution never tightens columns afterwards. Column order is the
union's, which is the
+ * canonical (name-sorted) order of the file schemas.
+ */
+ static Schema createdSchema(Schema merged, SchemaEvolutionConfig config) {
+ Pins pins = new Pins(config.getRequiredColumns());
+ List<Types.NestedField> fields = new ArrayList<>();
+ for (Types.NestedField field : merged.asStruct().fields()) {
+ fields.add(createdField(field, field.name(), pins));
+ }
+ return new Schema(fields);
+ }
+
+ private static Types.NestedField createdField(Types.NestedField field,
String path, Pins pins) {
+ boolean required = pins.isPinnedOrAncestorOfPin(path);
+ return Types.NestedField.from(field)
+ .ofType(createdType(field.type(), path, pins))
+ .isOptional(!required)
+ .build();
+ }
+
+ private static Type createdType(Type type, String path, Pins pins) {
+ if (type.isStructType()) {
+ List<Types.NestedField> fields = new ArrayList<>();
+ for (Types.NestedField field : type.asStructType().fields()) {
+ fields.add(createdField(field, path + "." + field.name(), pins));
+ }
+ return Types.StructType.of(fields);
+ }
+ if (type.isListType()) {
+ Types.ListType list = type.asListType();
+ String elementPath = path + ".element";
+ Type elementType = createdType(list.elementType(), elementPath, pins);
+ boolean required = pins.isPinnedOrAncestorOfPin(elementPath);
+ return required
+ ? Types.ListType.ofRequired(list.elementId(), elementType)
+ : Types.ListType.ofOptional(list.elementId(), elementType);
+ }
+ if (type.isMapType()) {
+ Types.MapType map = type.asMapType();
+ String valuePath = path + ".value";
+ Type valueType = createdType(map.valueType(), valuePath, pins);
+ boolean required = pins.isPinnedOrAncestorOfPin(valuePath);
+ return required
+ ? Types.MapType.ofRequired(map.keyId(), map.valueId(),
map.keyType(), valueType)
+ : Types.MapType.ofOptional(map.keyId(), map.valueId(),
map.keyType(), valueType);
+ }
+ return type;
+ }
+
+ private static void reportIncompatible(
+ TableIdentifier tableId,
+ List<Incompatible> incompatible,
+ IncompatibleSchemaHandling handling,
+ String consequence) {
+ long files = 0;
+ for (Incompatible item : incompatible) {
+ files += item.files;
+ }
+ if (handling == IncompatibleSchemaHandling.FAIL_PIPELINE) {
+ throw new IncompatibleSchemaException(
+ "Incompatible schemas for "
+ + tableId
+ + " ("
+ + incompatible.size()
+ + " schema(s), "
+ + files
+ + " file(s)); "
+ + consequence
+ + ":\n "
+ + joinLines(incompatible));
+ }
+ LOG.warn(
+ "Skipping {} incompatible schema(s) ({} file(s)) for {}; their files
will be routed to"
+ + " the error output:\n {}",
+ incompatible.size(),
+ files,
+ tableId,
+ joinLines(incompatible));
+ }
+
+ /**
+ * Stages one union per accepted schema onto {@code txn}: a scratch
transaction on the evolve path
+ * (its per-schema versions stay in memory; only the folded result is ever
committed), the create
+ * transaction on the create path. A schema can conflict with another
schema's additions, which
+ * only surfaces while staging and poisons the transaction, so on a conflict
the offender is
+ * returned for the caller to drop and retry with a fresh transaction.
+ */
+ private static @Nullable Accepted stageAll(
+ Transaction txn, List<Accepted> accepted, List<Incompatible>
incompatible) {
+ for (Accepted item : accepted) {
+ // classify checked each schema against the base table only; a column
that differs only in
+ // case from one an EARLIER schema of the window added would union as a
second column.
+ List<SchemaChange> collisions = new ArrayList<>();
+ ColumnNameChecks.findCaseCollisions(
+ txn.table().schema().asStruct(), item.schema.asStruct(), "",
collisions);
+ if (!collisions.isEmpty()) {
+ incompatible.add(
+ new Incompatible(
+ item.json,
+ item.files,
+ "conflicts with another file schema in the same window: " +
describe(collisions)));
+ return item;
+ }
+ // Both caught types carry staging conflicts: ValidationException from
Schema
+ // construction at apply ("multiple fields for name"),
IllegalArgumentException from
+ // SchemaUpdate preconditions ("Cannot change column type").
+ try {
+ stage(txn, item);
+ } catch (ValidationException | IllegalArgumentException e) {
+ incompatible.add(
+ new Incompatible(
+ item.json,
+ item.files,
+ "conflicts with another file schema in the same window: "
+ + AddFiles.errorMessage(e)));
+ return item;
+ }
+ }
+ return null;
+ }
+
+ private static String describe(List<SchemaChange> changes) {
+ List<String> descriptions = new ArrayList<>();
+ for (SchemaChange change : changes) {
+ descriptions.add(change.description);
+ }
+ return String.join("; ", descriptions);
+ }
+
/**
* Iceberg refreshes the table on every {@code newTransaction()}, so a
concurrent schema commit
* can slip between two transactions here. Any drift from the snapshot the
window classified
@@ -325,8 +614,10 @@ final class CommitSchemaUnion {
private static void stage(Transaction txn, Accepted item) {
UpdateSchema update = txn.updateSchema().unionByNameWith(item.schema);
- for (String path : item.delta.absentRequiredPaths()) {
- update = update.makeColumnOptional(path);
+ if (item.delta != null) {
+ for (String path : item.delta.absentRequiredPaths()) {
+ update = update.makeColumnOptional(path);
+ }
}
update.commit();
}
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/Pins.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/Pins.java
index b516bfeefed..2ed865b9b87 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/Pins.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/Pins.java
@@ -43,6 +43,11 @@ final class Pins {
return dotted.contains(dottedPath);
}
+ /** Whether {@code dottedPath} is pinned or has a pin somewhere below it. */
+ boolean isPinnedOrAncestorOfPin(String dottedPath) {
+ return isPinned(dottedPath) || pinnedColumnBeneath(dottedPath) != null;
+ }
+
/**
* Returns the pinned column strictly below {@code dottedPath} (the
lexicographically first when
* several are), or null when there is none. Columns below a pin, or beside
it, have none.
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CommitSchemaOnceTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CommitSchemaOnceTest.java
new file mode 100644
index 00000000000..e3d6889b161
--- /dev/null
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CommitSchemaOnceTest.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.iceberg;
+
+import static org.apache.iceberg.types.Types.NestedField.optional;
+import static org.apache.iceberg.types.Types.NestedField.required;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import java.util.Arrays;
+import java.util.List;
+import org.apache.beam.sdk.PipelineResult;
+import
org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.IncompatibleSchemaHandling;
+import org.apache.beam.sdk.metrics.MetricNameFilter;
+import org.apache.beam.sdk.metrics.MetricResult;
+import org.apache.beam.sdk.metrics.MetricsFilter;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.values.PCollection;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SchemaParser;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.types.Types;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.rules.TestName;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class CommitSchemaOnceTest {
+ @ClassRule public static final TemporaryFolder TEMPORARY_FOLDER = new
TemporaryFolder();
+
+ @Rule
+ public transient TestDataWarehouse warehouse = new
TestDataWarehouse(TEMPORARY_FOLDER, "default");
+
+ @Rule public TestName testName = new TestName();
+ @Rule public final TestPipeline pipeline = TestPipeline.create();
+
+ private static final Schema TABLE =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "name",
Types.StringType.get()));
+
+ @Test
+ public void testCommitsTheWindowsSchemasAndEmitsTheSchemaId() {
+ TableIdentifier tableId = TableIdentifier.of("default",
testName.getMethodName());
+ warehouse.createTable(tableId, TABLE);
+ IcebergCatalogConfig catalogConfig =
+ IcebergCatalogConfig.builder()
+ .setCatalogProperties(
+ ImmutableMap.of("type", "hadoop", "warehouse",
warehouse.location))
+ .build();
+ Schema file =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "email",
Types.StringType.get()));
+ List<CollectDistinctSchemas.SchemaGroup> schemas =
+ Arrays.asList(
+ CollectDistinctSchemas.SchemaGroup.of(
+ SchemaParser.toJson(FileSchemas.canonical(file)), 3L,
Arrays.asList()));
+
+ PCollection<Long> schemaIds =
+ pipeline
+ .apply(
+
Create.of(Arrays.asList(schemas)).withCoder(CollectDistinctSchemas.outputCoder()))
+ .apply(
+ ParDo.of(
+ new CommitSchemaOnce(
+ catalogConfig,
+ "default." + testName.getMethodName(),
+
SchemaEvolutionConfig.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION),
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ new CommitSchemaUnion.TableCreation(null, null,
null))));
+ PAssert.that(schemaIds).containsInAnyOrder(1L);
+ PipelineResult result = pipeline.run();
+ result.waitUntilFinish();
+ assertEquals(1, commitsCounted(result));
+
+ Table table = warehouse.loadTable(tableId);
+ assertNotNull(table.schema().findField("email"));
+ }
+
+ /** A window the table already covers commits nothing and does not count as
a commit. */
+ @Test
+ public void testNoOpWindowDoesNotCountACommit() {
+ TableIdentifier tableId = TableIdentifier.of("default",
testName.getMethodName());
+ warehouse.createTable(tableId, TABLE);
+ Table table = warehouse.loadTable(tableId);
+ table
+ .updateProperties()
+ .set(
+ TableProperties.DEFAULT_NAME_MAPPING,
NameMappingUtils.regenerate(table.schema(), null))
+ .commit();
+ IcebergCatalogConfig catalogConfig =
+ IcebergCatalogConfig.builder()
+ .setCatalogProperties(
+ ImmutableMap.of("type", "hadoop", "warehouse",
warehouse.location))
+ .build();
+ Schema covered = new Schema(required(1, "id", Types.LongType.get()));
+ List<CollectDistinctSchemas.SchemaGroup> schemas =
+ Arrays.asList(
+ CollectDistinctSchemas.SchemaGroup.of(
+ SchemaParser.toJson(FileSchemas.canonical(covered)), 2L,
Arrays.asList()));
+
+ pipeline
+
.apply(Create.of(Arrays.asList(schemas)).withCoder(CollectDistinctSchemas.outputCoder()))
+ .apply(
+ ParDo.of(
+ new CommitSchemaOnce(
+ catalogConfig,
+ "default." + testName.getMethodName(),
+
SchemaEvolutionConfig.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION),
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ new CommitSchemaUnion.TableCreation(null, null, null))));
+ PipelineResult result = pipeline.run();
+ result.waitUntilFinish();
+ assertEquals(0, commitsCounted(result));
+ }
+
+ private static long commitsCounted(PipelineResult result) {
+ long total = 0;
+ for (MetricResult<Long> counter :
+ result
+ .metrics()
+ .queryMetrics(
+ MetricsFilter.builder()
+ .addNameFilter(
+ MetricNameFilter.named(
+ CommitSchemaOnce.class,
CommitSchemaOnce.COMMITS_COUNTER))
+ .build())
+ .getCounters()) {
+ total += counter.getAttempted();
+ }
+ return total;
+ }
+}
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnionTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnionTest.java
index b0323064d38..7df6af49920 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnionTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/CommitSchemaUnionTest.java
@@ -29,6 +29,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
+import java.util.HashSet;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.beam.sdk.io.iceberg.CommitSchemaUnion.Committer;
@@ -44,6 +45,7 @@ import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.CommitFailedException;
import org.apache.iceberg.hadoop.HadoopCatalog;
import org.apache.iceberg.mapping.NameMapping;
+import org.apache.iceberg.types.TypeUtil;
import org.apache.iceberg.types.Types;
import org.junit.Before;
import org.junit.ClassRule;
@@ -75,6 +77,9 @@ public class CommitSchemaUnionTest {
private static final SchemaEvolutionConfig ADDITION_ONLY =
SchemaEvolutionConfig.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION);
+ private static final CommitSchemaUnion.TableCreation NO_CREATION =
+ new CommitSchemaUnion.TableCreation(null, null, null);
+
private HadoopCatalog catalog;
private TableIdentifier tableId;
@@ -108,6 +113,7 @@ public class CommitSchemaUnionTest {
Arrays.asList(schemas),
config,
handling,
+ NO_CREATION,
CommitSchemaUnion.DEFAULT_COMMITTER);
}
@@ -115,6 +121,13 @@ public class CommitSchemaUnionTest {
return catalog.loadTable(tableId);
}
+ /** Full-schema comparison, field ids normalized; string form so a failure
shows the diff. */
+ private static void assertSameSchema(Schema expected, Schema actual) {
+ assertEquals(
+ TypeUtil.assignIncreasingFreshIds(expected).asStruct().toString(),
+ TypeUtil.assignIncreasingFreshIds(actual).asStruct().toString());
+ }
+
private static String metadataLocation(Table table) {
return ((BaseTable) table).operations().current().metadataFileLocation();
}
@@ -539,6 +552,7 @@ public class CommitSchemaUnionTest {
Arrays.asList(files(b, 2), files(a, 1)),
ALL,
IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
CommitSchemaUnion.DEFAULT_COMMITTER);
assertTrue(first.sameSchema(catalog.loadTable(other).schema()));
}
@@ -730,6 +744,7 @@ public class CommitSchemaUnionTest {
Arrays.asList(files(file, 1)),
ALL,
IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
flakyThenExternalChange);
Table table = load();
assertEquals(2, attempts.get());
@@ -759,10 +774,386 @@ public class CommitSchemaUnionTest {
Arrays.asList(files(file, 1)),
ALL,
IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
alwaysFails));
assertEquals(CommitSchemaUnion.MAX_ATTEMPTS, attempts.get());
}
+ // ---- create path
+
+ private TableIdentifier missing() {
+ return TableIdentifier.of("default", testName.getMethodName() + "_new");
+ }
+
+ private long commitTo(
+ TableIdentifier id,
+ SchemaEvolutionConfig config,
+ IncompatibleSchemaHandling handling,
+ CommitSchemaUnion.TableCreation creation,
+ CollectDistinctSchemas.SchemaGroup... schemas) {
+ return CommitSchemaUnion.commit(
+ catalog,
+ id,
+ Arrays.asList(schemas),
+ config,
+ handling,
+ creation,
+ CommitSchemaUnion.DEFAULT_COMMITTER);
+ }
+
+ @Test
+ public void testMissingTableIsCreatedFromTheUnion() {
+ TableIdentifier id = missing();
+ Schema seed =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ required(2, "region", Types.StringType.get()),
+ optional(3, "email", Types.StringType.get()));
+ Schema other =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "extra",
Types.LongType.get()));
+ CommitSchemaUnion.TableCreation creation =
+ new CommitSchemaUnion.TableCreation(
+ Arrays.asList("region"), null,
java.util.Collections.singletonMap("k", "v"));
+ long schemaId =
+ commitTo(
+ id,
+ ALL,
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ creation,
+ files(seed, 5),
+ files(other, 1));
+ Table table = catalog.loadTable(id);
+ assertEquals(table.schema().schemaId(), schemaId);
+ // canonical (sorted) seed columns first, the union's addition last
+ assertSameSchema(
+ new Schema(
+ optional(1, "email", Types.StringType.get()),
+ optional(2, "id", Types.LongType.get()),
+ optional(3, "region", Types.StringType.get()),
+ optional(4, "extra", Types.LongType.get())),
+ table.schema());
+ assertEquals("region", table.spec().fields().get(0).name());
+ assertEquals("v", table.properties().get("k"));
+
assertNotNull(table.properties().get(TableProperties.DEFAULT_NAME_MAPPING));
+ assertEquals("born with one schema version", 1, table.schemas().size());
+ }
+
+ @Test
+ public void testPartitionFieldFromNonSeedSchemaResolves() {
+ TableIdentifier id = missing();
+ Schema seed =
+ new Schema(
+ required(1, "id", Types.LongType.get()), required(2, "region",
Types.StringType.get()));
+ Schema other =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "extra",
Types.StringType.get()));
+ CommitSchemaUnion.TableCreation creation =
+ new CommitSchemaUnion.TableCreation(Arrays.asList("extra"), null,
null);
+ commitTo(
+ id,
+ ALL,
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ creation,
+ files(seed, 5),
+ files(other, 1));
+ Table table = catalog.loadTable(id);
+ assertEquals("extra", table.spec().fields().get(0).name());
+ }
+
+ @Test
+ public void testPinnedColumnsAndAncestorsAreRequiredOnCreate() {
+ TableIdentifier id = missing();
+ SchemaEvolutionConfig pinned =
+ SchemaEvolutionConfig.builder()
+ .setOptions(EnumSet.allOf(SchemaEvolutionOption.class))
+ .setRequiredColumns(new HashSet<>(Arrays.asList("id",
"address.city")))
+ .build();
+ Schema seed =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ required(2, "region", Types.StringType.get()),
+ optional(
+ 3,
+ "address",
+ Types.StructType.of(
+ optional(4, "city", Types.StringType.get()),
+ optional(5, "zip", Types.IntegerType.get()))));
+ commitTo(id, pinned, IncompatibleSchemaHandling.FAIL_PIPELINE,
NO_CREATION, files(seed, 1));
+ assertSameSchema(
+ new Schema(
+ required(
+ 1,
+ "address",
+ Types.StructType.of(
+ required(2, "city", Types.StringType.get()),
+ optional(3, "zip", Types.IntegerType.get()))),
+ required(4, "id", Types.LongType.get()),
+ optional(5, "region", Types.StringType.get())),
+ catalog.loadTable(id).schema());
+ }
+
+ /** A pin no file schema carries cannot shape the table: fail loudly under
FAIL_PIPELINE. */
+ @Test
+ public void testUnmatchedPinFailsCreationUnderFailPipeline() {
+ TableIdentifier id = missing();
+ SchemaEvolutionConfig pinned =
+ SchemaEvolutionConfig.builder()
+ .setOptions(EnumSet.allOf(SchemaEvolutionOption.class))
+ .setRequiredColumns(Collections.singleton("email"))
+ .build();
+ Schema seed = new Schema(required(1, "id", Types.LongType.get()));
+ IncompatibleSchemaException e =
+ assertThrows(
+ IncompatibleSchemaException.class,
+ () ->
+ commitTo(
+ id,
+ pinned,
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
+ files(seed, 1)));
+ assertTrue(e.getMessage(), e.getMessage().contains("email"));
+ assertFalse(catalog.tableExists(id));
+ }
+
+ /**
+ * Iceberg resolves the short container spelling a.b for a.element.b, but
the pin walk matches
+ * segments, so such a pin would silently shape only the ancestors; it is
rejected instead.
+ */
+ @Test
+ public void testShortSpellingPinFailsCreationUnderFailPipeline() {
+ TableIdentifier id = missing();
+ SchemaEvolutionConfig pinned =
+ SchemaEvolutionConfig.builder()
+ .setOptions(EnumSet.allOf(SchemaEvolutionOption.class))
+ .setRequiredColumns(Collections.singleton("l.q"))
+ .build();
+ Schema seed =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ optional(
+ 2,
+ "l",
+ Types.ListType.ofOptional(
+ 3, Types.StructType.of(optional(4, "q",
Types.IntegerType.get())))));
+ IncompatibleSchemaException e =
+ assertThrows(
+ IncompatibleSchemaException.class,
+ () ->
+ commitTo(
+ id,
+ pinned,
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
+ files(seed, 1)));
+ assertTrue(e.getMessage(), e.getMessage().contains("l.q"));
+ assertFalse(catalog.tableExists(id));
+ }
+
+ @Test
+ public void testUnmatchedPinWarnsAndCreatesUnderRouteToErrors() {
+ TableIdentifier id = missing();
+ SchemaEvolutionConfig pinned =
+ SchemaEvolutionConfig.builder()
+ .setOptions(EnumSet.allOf(SchemaEvolutionOption.class))
+ .setRequiredColumns(Collections.singleton("email"))
+ .build();
+ Schema seed = new Schema(required(1, "id", Types.LongType.get()));
+ commitTo(id, pinned, IncompatibleSchemaHandling.ROUTE_TO_ERRORS,
NO_CREATION, files(seed, 1));
+ assertSameSchema(
+ new Schema(optional(1, "id", Types.LongType.get())),
catalog.loadTable(id).schema());
+ }
+
+ // ---- createdSchema (direct)
+
+ @Test
+ public void testCreatedSchemaPinsHoldAtEveryLevel() {
+ Schema merged =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ optional(
+ 2,
+ "l",
+ Types.ListType.ofOptional(
+ 3, Types.StructType.of(optional(4, "q",
Types.IntegerType.get())))));
+ SchemaEvolutionConfig pinned =
+ SchemaEvolutionConfig.builder()
+ .setOptions(EnumSet.allOf(SchemaEvolutionOption.class))
+ .setRequiredColumns(Collections.singleton("l.element.q"))
+ .build();
+ Schema created = CommitSchemaUnion.createdSchema(merged, pinned);
+ assertSameSchema(
+ new Schema(
+ optional(1, "id", Types.LongType.get()),
+ required(
+ 2,
+ "l",
+ Types.ListType.ofRequired(
+ 3, Types.StructType.of(required(4, "q",
Types.IntegerType.get()))))),
+ created);
+ }
+
+ @Test
+ public void testCreatedSchemaEveryLevelOptionalExceptMapKeys() {
+ Schema schema =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ required(
+ 2,
+ "s",
+ Types.StructType.of(
+ required(3, "a", Types.IntegerType.get()),
+ required(
+ 4,
+ "items",
+ Types.ListType.ofRequired(
+ 5, Types.StructType.of(required(6, "qty",
Types.IntegerType.get())))))),
+ required(
+ 7,
+ "attrs",
+ Types.MapType.ofRequired(
+ 8,
+ 9,
+ Types.StructType.of(required(10, "k",
Types.StringType.get())),
+ Types.StructType.of(required(11, "v",
Types.IntegerType.get())))));
+ assertSameSchema(
+ new Schema(
+ optional(1, "id", Types.LongType.get()),
+ optional(
+ 2,
+ "s",
+ Types.StructType.of(
+ optional(3, "a", Types.IntegerType.get()),
+ optional(
+ 4,
+ "items",
+ Types.ListType.ofOptional(
+ 5, Types.StructType.of(optional(6, "qty",
Types.IntegerType.get())))))),
+ optional(
+ 7,
+ "attrs",
+ Types.MapType.ofOptional(
+ 8,
+ 9,
+ Types.StructType.of(required(10, "k",
Types.StringType.get())),
+ Types.StructType.of(optional(11, "v",
Types.IntegerType.get()))))),
+ CommitSchemaUnion.createdSchema(schema, ALL));
+ }
+
+ /** Options guard an existing table's schema; with no table there is nothing
to guard. */
+ @Test
+ public void testCreationIsNotGatedOnAnyParticularOption() {
+ TableIdentifier id = missing();
+ Schema seed =
+ new Schema(
+ required(1, "id", Types.LongType.get()), required(2, "region",
Types.StringType.get()));
+ commitTo(
+ id,
+ SchemaEvolutionConfig.of(SchemaEvolutionOption.ALLOW_TYPE_PROMOTION),
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
+ files(seed, 1));
+ assertSameSchema(
+ new Schema(
+ optional(1, "id", Types.LongType.get()), optional(2, "region",
Types.StringType.get())),
+ catalog.loadTable(id).schema());
+ }
+
+ @Test
+ public void testMissingTableWithoutSchemasIsNotCreated() {
+ TableIdentifier id = missing();
+ long result =
+ CommitSchemaUnion.commit(
+ catalog,
+ id,
+ new ArrayList<>(),
+ ALL,
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
+ CommitSchemaUnion.DEFAULT_COMMITTER);
+ assertEquals(CommitSchemaUnion.NO_TABLE, result);
+ assertFalse(catalog.tableExists(id));
+ }
+
+ @Test
+ public void testConflictOnCreateFailsWithoutCreating() {
+ TableIdentifier id = missing();
+ Schema asString =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "code",
Types.StringType.get()));
+ Schema asLong =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "code",
Types.LongType.get()));
+ assertThrows(
+ IncompatibleSchemaException.class,
+ () ->
+ commitTo(
+ id,
+ ALL,
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
+ files(asLong, 3),
+ files(asString, 1)));
+ assertFalse(catalog.tableExists(id));
+ }
+
+ @Test
+ public void testConflictOnCreateRoutesLoserAndCreates() {
+ TableIdentifier id = missing();
+ Schema asString =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "code",
Types.StringType.get()));
+ Schema asLong =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "code",
Types.LongType.get()));
+ commitTo(
+ id,
+ ALL,
+ IncompatibleSchemaHandling.ROUTE_TO_ERRORS,
+ NO_CREATION,
+ files(asLong, 3),
+ files(asString, 1));
+ assertSameSchema(
+ new Schema(
+ optional(1, "code", Types.LongType.get()), optional(2, "id",
Types.LongType.get())),
+ catalog.loadTable(id).schema());
+ }
+
+ @Test
+ public void testCreateRaceFallsBackToEvolvingTheExistingTable() {
+ TableIdentifier id = missing();
+ AtomicInteger attempts = new AtomicInteger();
+ Committer raced =
+ txn -> {
+ if (attempts.incrementAndGet() == 1) {
+ // someone else creates the table first
+ warehouse.createTable(id, TABLE);
+ throw new
org.apache.iceberg.exceptions.AlreadyExistsException("raced");
+ }
+ txn.commitTransaction();
+ };
+ Schema file =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ required(2, "region", Types.StringType.get()),
+ optional(3, "email", Types.StringType.get()));
+ CommitSchemaUnion.commit(
+ catalog,
+ id,
+ Arrays.asList(files(file, 1)),
+ ALL,
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
+ raced);
+ Table table = catalog.loadTable(id);
+ assertEquals(2, attempts.get());
+ assertNotNull(table.schema().findField("email"));
+ assertTrue(
+ "evolved, not recreated: name from TABLE is still there",
+ table.schema().findField("name") != null);
+ }
+
@Test
public void testEmptyInputCommitsNothing() {
seedNameMapping();
@@ -774,7 +1165,132 @@ public class CommitSchemaUnionTest {
none,
ALL,
IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
CommitSchemaUnion.DEFAULT_COMMITTER);
assertEquals(before, metadataLocation(load()));
}
+
+ /** The create path refuses the same column names classify refuses on the
evolve path. */
+ @Test
+ public void testInvalidColumnNamesCannotSeedACreatedTable() {
+ TableIdentifier id = missing();
+ Schema dotted = new Schema(optional(1, "a.b", Types.LongType.get()));
+ Schema caseDuplicate =
+ new Schema(
+ optional(1, "Id", Types.LongType.get()), optional(2, "id",
Types.LongType.get()));
+ IncompatibleSchemaException e =
+ assertThrows(
+ IncompatibleSchemaException.class,
+ () ->
+ commitTo(
+ id,
+ ALL,
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
+ files(dotted, 1)));
+ assertTrue(e.getMessage(), e.getMessage().contains("contains '.'"));
+ assertFalse(catalog.tableExists(id));
+
+ // nothing valid to seed from: routed, and no table
+ long none =
+ commitTo(
+ id,
+ ALL,
+ IncompatibleSchemaHandling.ROUTE_TO_ERRORS,
+ NO_CREATION,
+ files(dotted, 1),
+ files(caseDuplicate, 2));
+ assertEquals(CommitSchemaUnion.NO_TABLE, none);
+ assertFalse(catalog.tableExists(id));
+
+ // the valid schema alone seeds the table
+ Schema valid = new Schema(required(1, "id", Types.LongType.get()));
+ commitTo(
+ id,
+ ALL,
+ IncompatibleSchemaHandling.ROUTE_TO_ERRORS,
+ NO_CREATION,
+ files(dotted, 1),
+ files(caseDuplicate, 2),
+ files(valid, 1));
+ assertSameSchema(
+ new Schema(optional(1, "id", Types.LongType.get())),
catalog.loadTable(id).schema());
+ }
+
+ private static final Schema WITH_EMAIL_UPPER =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ optional(2, "name", Types.StringType.get()),
+ optional(3, "score", Types.FloatType.get()),
+ required(4, "region", Types.StringType.get()),
+ optional(5, "Email", Types.StringType.get()));
+ private static final Schema WITH_EMAIL_LOWER =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ optional(2, "name", Types.StringType.get()),
+ optional(3, "score", Types.FloatType.get()),
+ required(4, "region", Types.StringType.get()),
+ optional(5, "email", Types.StringType.get()));
+
+ /**
+ * Each schema is fine against the base table; against each other they
differ only in case, which
+ * the fold must refuse like classify refuses it against the table. The
later schema loses.
+ */
+ @Test
+ public void testCaseCollidingSchemasInOneWindowKeepTheFirst() {
+ IncompatibleSchemaException e =
+ assertThrows(
+ IncompatibleSchemaException.class,
+ () ->
+ commit(
+ ALL,
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ files(WITH_EMAIL_UPPER, 3),
+ files(WITH_EMAIL_LOWER, 2)));
+ assertTrue(e.getMessage(), e.getMessage().contains("differs only in
case"));
+ assertTrue(e.getMessage(), e.getMessage().contains("another file schema in
the same window"));
+ assertSameSchema(TABLE, load().schema());
+
+ commit(
+ ALL,
+ IncompatibleSchemaHandling.ROUTE_TO_ERRORS,
+ files(WITH_EMAIL_UPPER, 3),
+ files(WITH_EMAIL_LOWER, 2));
+ assertSameSchema(WITH_EMAIL_UPPER, load().schema());
+ }
+
+ @Test
+ public void testCaseCollidingSchemasOnCreateKeepTheFirst() {
+ TableIdentifier id = missing();
+ IncompatibleSchemaException e =
+ assertThrows(
+ IncompatibleSchemaException.class,
+ () ->
+ commitTo(
+ id,
+ ALL,
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ NO_CREATION,
+ files(WITH_EMAIL_UPPER, 3),
+ files(WITH_EMAIL_LOWER, 2)));
+ assertTrue(e.getMessage(), e.getMessage().contains("differs only in
case"));
+ assertFalse(catalog.tableExists(id));
+
+ commitTo(
+ id,
+ ALL,
+ IncompatibleSchemaHandling.ROUTE_TO_ERRORS,
+ NO_CREATION,
+ files(WITH_EMAIL_UPPER, 3),
+ files(WITH_EMAIL_LOWER, 2));
+ // created columns: all optional, canonical (name-sorted, upper case
first) order
+ assertSameSchema(
+ new Schema(
+ optional(1, "Email", Types.StringType.get()),
+ optional(2, "id", Types.LongType.get()),
+ optional(3, "name", Types.StringType.get()),
+ optional(4, "region", Types.StringType.get()),
+ optional(5, "score", Types.FloatType.get())),
+ catalog.loadTable(id).schema());
+ }
}