diff --git a/.github/trigger_files/IO_Iceberg_Integration_Tests.json b/.github/trigger_files/IO_Iceberg_Integration_Tests.json index e1430c74e62c..6d9121eaaa4b 100644 --- a/.github/trigger_files/IO_Iceberg_Integration_Tests.json +++ b/.github/trigger_files/IO_Iceberg_Integration_Tests.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run.", - "modification": 7 + "modification": 8 } diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java index 75b1a0144e22..4093b2fb6e5d 100644 --- a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java +++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java @@ -42,9 +42,9 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.beam.sdk.coders.KvCoder; +import org.apache.beam.sdk.coders.RowCoder; import org.apache.beam.sdk.coders.VarIntCoder; import org.apache.beam.sdk.coders.VarLongCoder; -import org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.IncompatibleSchemaHandling; import org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.UnverifiableFileHandling; import org.apache.beam.sdk.metrics.Counter; import org.apache.beam.sdk.schemas.Schema; @@ -54,6 +54,7 @@ import org.apache.beam.sdk.state.StateSpecs; import org.apache.beam.sdk.state.ValueState; import org.apache.beam.sdk.transforms.Combine; +import org.apache.beam.sdk.transforms.Create; import org.apache.beam.sdk.transforms.DoFn; import org.apache.beam.sdk.transforms.GroupIntoBatches; import org.apache.beam.sdk.transforms.PTransform; @@ -123,7 +124,8 @@ * and committed as snapshots. * *
Outputs: {@code snapshots} (one row per commit), {@code errors} (one row per file that could - * not be registered: {@code file}, {@code error}). + * not be registered: {@code file}, {@code error}), and {@code dry_run_report} when a dry run is + * configured. * *
Schema evolution. With a {@link SchemaEvolutionConfig} whose options are set, a * pre-pass reads every Parquet footer, classifies the change each distinct file schema needs on the @@ -135,7 +137,9 @@ * {@code errors} (see {@link SchemaEvolutionConfig.IncompatibleSchemaHandling}). The per-file * checks read Parquet footers: an ORC or Avro file, or a pinned column the footer has no null count * for, cannot be verified and goes to {@code errors} unless {@link - * SchemaEvolutionConfig.UnverifiableFileHandling#ACCEPT} registers it on trust. Schema evolution + * SchemaEvolutionConfig.UnverifiableFileHandling#ACCEPT} registers it on trust. When the table does + * not exist, the pre-pass creates it from the union of the file schemas; if no readable Parquet + * schema can seed it, nothing is created and every file goes to {@code errors}. Schema evolution * currently requires bounded input; unbounded input with options set is rejected at construction. * *
{@code
@@ -154,6 +158,10 @@
public class AddFiles extends PTransform, PCollectionRowTuple> {
static final String OUTPUT_TAG = "snapshots";
static final String ERROR_TAG = "errors";
+
+ /** Only present with {@link SchemaEvolutionConfig#getDryRun()}. */
+ static final String DRY_RUN_TAG = "dry_run_report";
+
private static final Duration DEFAULT_TRIGGER_INTERVAL = Duration.standardMinutes(10);
private static final Counter numManifestFilesAdded =
counter(AddFiles.class, "numManifestFilesAdded");
@@ -260,7 +268,20 @@ public PCollectionRowTuple expand(PCollection input) {
PCollection paths = input;
if (evolution.isEnabled()) {
- paths = gateOnSchemaCommit(input);
+ // one commit per window, and for bounded input the window is the whole input
+ PCollection windowed =
+ input.apply("PrePassGlobalWindow", Window.into(new GlobalWindows()));
+ PCollection> schemas = distinctSchemas(windowed);
+ CommitSchemaUnion.Settings settings =
+ new CommitSchemaUnion.Settings(
+ evolution,
+ evolution.incompatibleSchemaHandlingFor(input.isBounded()),
+ new CommitSchemaUnion.NewTableSettings(partitionFields, sortFields, tableProps));
+ if (evolution.getDryRun()) {
+ return report(schemas, settings);
+ }
+ PCollection committed = commitSchema(schemas, settings);
+ paths = windowed.apply("WaitForSchemaCommit", Wait.on(committed));
}
PCollectionTuple dataFiles =
@@ -320,39 +341,49 @@ public PCollectionRowTuple expand(PCollection input) {
OUTPUT_TAG, snapshots, ERROR_TAG, dataFiles.get(ERRORS).setRowSchema(ERROR_SCHEMA));
}
- /**
- * Holds every path until the schema commit has landed. The pre-pass window is the unit of commit,
- * and for bounded input that unit is the whole input: paths are rewindowed into the global window
- * first, so one commit covers everything and the Wait.on signal lines up with the main input
- * whatever windowing the caller applied upstream.
- */
- private PCollection gateOnSchemaCommit(PCollection input) {
- PCollection windowed =
- input.apply("PrePassGlobalWindow", Window.into(new GlobalWindows()));
- CommitSchemaUnion.TableCreation creation =
- new CommitSchemaUnion.TableCreation(partitionFields, sortFields, tableProps);
- // unset handling follows the mode (fail the job in batch, route in streaming); the pre-pass
- // only runs on bounded input, see expand
- IncompatibleSchemaHandling onIncompatible =
- evolution.incompatibleSchemaHandlingFor(input.isBounded());
- PCollection signal =
- windowed
- .apply("ReadFooterSchema", ParDo.of(new ReadFooterSchema()))
- .setCoder(CollectDistinctSchemas.groupCoder())
- .apply(
- "CollectDistinctSchemas",
- Combine.globally(new CollectDistinctSchemas()).withoutDefaults())
+ private PCollection> distinctSchemas(
+ PCollection windowed) {
+ return windowed
+ .apply("ReadFooterSchema", ParDo.of(new ReadFooterSchema(evolution)))
+ .setCoder(CollectDistinctSchemas.groupCoder())
+ .apply(
+ "CollectDistinctSchemas",
+ Combine.globally(new CollectDistinctSchemas()).withoutDefaults());
+ }
+
+ /** Commits the plan for the window's schemas once; the signal releases the gated paths. */
+ private PCollection commitSchema(
+ PCollection> schemas,
+ CommitSchemaUnion.Settings settings) {
+ return schemas.apply(
+ "CommitSchemaOnce",
+ ParDo.of(new CommitSchemaOnce(catalogConfig, tableIdentifier, settings, committer)));
+ }
+
+ /** Reports the plan for the window's schemas instead; nothing is committed or registered. */
+ private PCollectionRowTuple report(
+ PCollection> schemas,
+ CommitSchemaUnion.Settings settings) {
+ PCollection report =
+ schemas
.apply(
- "CommitSchemaOnce",
- ParDo.of(
- new CommitSchemaOnce(
- catalogConfig,
- tableIdentifier,
- evolution,
- onIncompatible,
- creation,
- committer)));
- return windowed.apply("WaitForSchemaCommit", Wait.on(signal));
+ "DryRunReport",
+ ParDo.of(new DryRunReport(catalogConfig, tableIdentifier, settings)))
+ .setRowSchema(DryRunReport.REPORT_SCHEMA);
+
+ PCollection emptySnapshots =
+ schemas
+ .getPipeline()
+ .apply("NoSnapshots", Create.empty(RowCoder.of(SnapshotInfo.getSchema())))
+ .setRowSchema(SnapshotInfo.getSchema());
+ PCollection emptyErrors =
+ schemas
+ .getPipeline()
+ .apply("NoErrors", Create.empty(RowCoder.of(ERROR_SCHEMA)))
+ .setRowSchema(ERROR_SCHEMA);
+ return PCollectionRowTuple.of(OUTPUT_TAG, emptySnapshots)
+ .and(ERROR_TAG, emptyErrors)
+ .and(DRY_RUN_TAG, report);
}
/** Test hook: how the schema pre-pass commits its transaction. */
@@ -394,6 +425,7 @@ static class ConvertToDataFile extends DoFn {
private final SchemaEvolutionConfig evolution;
private transient @MonotonicNonNull BoundedAsyncTasks tasks;
private transient volatile @MonotonicNonNull Table table;
+ private transient volatile boolean tableMissing;
private transient @MonotonicNonNull Set warned;
private final AtomicBoolean refreshedThisBundle = new AtomicBoolean();
@@ -441,6 +473,9 @@ public ConvertToDataFile(
static final String UNREADABLE_SCHEMA_ERROR = "Could not read the file's schema: ";
static final String UNCOVERED_ERROR = "Table schema does not cover the file after refresh: ";
static final String PINNED_COLUMN_ERROR = "Pinned required column ";
+ static final String MISSING_TABLE_ERROR =
+ "Table does not exist and the schema pre-pass could not create it (no readable Parquet"
+ + " schema seeded it, or every file schema was refused): ";
static final String UNCHECKED_FORMAT_ERROR =
"Schema evolution is enabled but coverage and pin checks support only Parquet;"
+ " refusing to register an unchecked file of format ";
@@ -598,6 +633,10 @@ private Callable createProcessTask(
// ---- Infrastructure phase. Failures propagate so the runner retries the bundle;
// per-file error rows here would silently drop in-flight files on a transient blip.
// Only conditions that are properties of the file go to the error output.
+ if (tableMissing) {
+ return errorResult(
+ filePath, MISSING_TABLE_ERROR + identifier, timestamp, window, paneInfo);
+ }
if (table == null) {
synchronized (this) {
if (table == null) {
@@ -605,6 +644,10 @@ private Callable createProcessTask(
table = getOrCreateTable(filePath, format);
} catch (FileNotFoundException e) {
return errorResult(filePath, errorMessage(e), timestamp, window, paneInfo);
+ } catch (NoSuchTableException e) {
+ tableMissing = true;
+ return errorResult(
+ filePath, MISSING_TABLE_ERROR + identifier, timestamp, window, paneInfo);
}
}
}
@@ -835,6 +878,10 @@ private Table getOrCreateTable(String filePath, FileFormat format) throws IOExce
try {
return catalogConfig.catalog().loadTable(tableId);
} catch (NoSuchTableException e) {
+ if (evolution.isEnabled()) {
+ // the pre-pass is the only creator then, and it has already declined
+ throw e;
+ }
try {
org.apache.iceberg.Schema schema = getSchema(filePath, format);
PartitionSpec spec = PartitionUtils.toPartitionSpec(partitionFields, schema);
diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFilesSchemaTransformProvider.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFilesSchemaTransformProvider.java
index fa016ddca862..c5cc758c555b 100644
--- a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFilesSchemaTransformProvider.java
+++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFilesSchemaTransformProvider.java
@@ -120,7 +120,8 @@ public static Builder builder() {
public abstract @Nullable List getSortFields();
@SchemaFieldDescription(
- "Lets the transform change the table schema so that every file's columns are covered."
+ "Lets the transform change the table schema so that the table has a column for every"
+ + " column the files have."
+ " Values: ALLOW_FIELD_ADDITION (columns a file has and the table lacks are added, as"
+ " optional), ALLOW_FIELD_RELAXATION (a required table column becomes optional when a"
+ " file lacks it or may hold nulls in it), ALLOW_TYPE_PROMOTION (a column type is"
@@ -131,8 +132,10 @@ public static Builder builder() {
+ " that is not allowed is incompatible; see incompatible_schema_handling. Only"
+ " Parquet files can be checked: ORC and Avro files are sent to the error output"
+ " unless unverifiable_file_handling is ACCEPT. Files sent to the error output are"
- + " dropped unless error_handling is set. Batch pipelines only; streaming pipelines"
- + " cannot use schema evolution yet.")
+ + " dropped unless error_handling is set. If the table does not exist it is created"
+ + " from the union of the Parquet schemas; with none, every file goes to the error"
+ + " output. Batch pipelines only; streaming pipelines cannot use schema evolution"
+ + " yet.")
public abstract @Nullable List getSchemaEvolutionOptions();
@SchemaFieldDescription(
@@ -145,6 +148,19 @@ public static Builder builder() {
+ " Requires schema_evolution_options.")
public abstract @Nullable List getRequiredColumns();
+ @SchemaFieldDescription(
+ "When true, nothing is committed or registered: the transform reads the files' schemas"
+ + " and emits a `dry_run_report` output with one row that describes what a real run"
+ + " would do. Its `allowed` field is true when every file schema can be merged and the"
+ + " configuration raises no problem; otherwise its `reason` field says what a real run"
+ + " would do about it (fail, or route the files to the error output). Its `schemas`"
+ + " field lists each distinct file schema with the changes a real run would make for"
+ + " it and, when it cannot be merged, why. The output only exists when this is set;"
+ + " consume it as input: `.dry_run_report`. Against a missing"
+ + " table, a REST catalog needs table-create permission even though no table is"
+ + " created.")
+ public abstract @Nullable Boolean getDryRun();
+
@SchemaFieldDescription(
"What happens when a file's schema cannot be made to fit the table: it needs a change"
+ " that is not allowed, or it conflicts with the table or with another file."
@@ -158,8 +174,9 @@ public static Builder builder() {
+ " Parquet footers only), or a Parquet file with no null-count statistics for a"
+ " required column (statistics disabled by the writer, or a column under a list or"
+ " map). REJECT (the default) sends the file to the error output (see"
- + " error_handling). ACCEPT registers it without checks, counted and logged. A file"
- + " that fails a check is always sent to the error output. An accepted file that"
+ + " error_handling). ACCEPT registers it without the checks; such files are counted"
+ + " and logged. A file that fails a check is always sent to the error output. An"
+ + " accepted file that"
+ " lacks a required column, or holds nulls in it, makes reads of the table fail.")
public abstract @Nullable String getUnverifiableFileHandling();
@@ -198,6 +215,8 @@ public abstract static class Builder {
public abstract Builder setUnverifiableFileHandling(String handling);
+ public abstract Builder setDryRun(Boolean dryRun);
+
public abstract Configuration build();
}
@@ -207,19 +226,21 @@ public abstract static class Builder {
List pins = getRequiredColumns();
String handlingName = getIncompatibleSchemaHandling();
String unverifiableName = getUnverifiableFileHandling();
+ boolean dryRun = Boolean.TRUE.equals(getDryRun());
boolean nothingSet =
(optionNames == null || optionNames.isEmpty())
&& (pins == null || pins.isEmpty())
&& handlingName == null
- && unverifiableName == null;
+ && unverifiableName == null
+ && !dryRun;
if (nothingSet) {
return null;
}
// SchemaEvolutionConfig.build() checks this too; this copy names the YAML keys
Preconditions.checkArgument(
optionNames != null && !optionNames.isEmpty(),
- "required_columns, incompatible_schema_handling and unverifiable_file_handling need at"
- + " least one schema_evolution_options entry");
+ "required_columns, incompatible_schema_handling, unverifiable_file_handling and"
+ + " dry_run need at least one schema_evolution_options entry");
Set options = EnumSet.noneOf(SchemaEvolutionOption.class);
for (String name : checkStateNotNull(optionNames)) {
options.add(parseEnum(SchemaEvolutionOption.class, name, "schema_evolution_options"));
@@ -228,6 +249,7 @@ public abstract static class Builder {
if (pins != null) {
builder = builder.setRequiredColumns(new LinkedHashSet<>(pins));
}
+ builder = builder.setDryRun(dryRun);
if (handlingName != null) {
SchemaEvolutionConfig.IncompatibleSchemaHandling handling =
parseEnum(
@@ -316,6 +338,9 @@ public PCollectionRowTuple expand(PCollectionRowTuple input) {
if (errorHandling != null) {
output = output.and(errorHandling.getOutput(), result.get(ERROR_TAG));
}
+ if (Boolean.TRUE.equals(configuration.getDryRun())) {
+ output = output.and(AddFiles.DRY_RUN_TAG, result.get(AddFiles.DRY_RUN_TAG));
+ }
return output;
}
}
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
index 6fd5171c2eae..768fb564de9b 100644
--- 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
@@ -20,7 +20,6 @@
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;
@@ -37,34 +36,23 @@ class CommitSchemaOnce extends DoFn, Lo
private final IcebergCatalogConfig catalogConfig;
private final String identifier;
- private final SchemaEvolutionConfig config;
- private final IncompatibleSchemaHandling handling;
- private final CommitSchemaUnion.TableCreation creation;
+ private final CommitSchemaUnion.Settings settings;
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);
+ IcebergCatalogConfig catalogConfig, String identifier, CommitSchemaUnion.Settings settings) {
+ this(catalogConfig, identifier, settings, CommitSchemaUnion.DEFAULT_COMMITTER);
}
CommitSchemaOnce(
IcebergCatalogConfig catalogConfig,
String identifier,
- SchemaEvolutionConfig config,
- IncompatibleSchemaHandling handling,
- CommitSchemaUnion.TableCreation creation,
+ CommitSchemaUnion.Settings settings,
CommitSchemaUnion.Committer committer) {
this.catalogConfig = catalogConfig;
this.identifier = identifier;
- this.config = config;
- this.handling = handling;
- this.creation = creation;
+ this.settings = settings;
this.committer = committer;
}
@@ -82,8 +70,7 @@ public void process(
committer.commit(txn);
numSchemaCommits.inc();
};
- long schemaId =
- CommitSchemaUnion.commit(catalog, tableId, schemas, config, handling, creation, counting);
+ long schemaId = CommitSchemaUnion.commit(catalog, tableId, schemas, settings, 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 1e145a122c81..093749798780 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
@@ -17,6 +17,7 @@
*/
package org.apache.beam.sdk.io.iceberg;
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkState;
import java.io.Serializable;
@@ -25,45 +26,39 @@
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.metrics.Counter;
+import org.apache.beam.sdk.metrics.Metrics;
import org.apache.beam.sdk.util.BackOff;
import org.apache.beam.sdk.util.BackOffUtils;
import org.apache.beam.sdk.util.FluentBackoff;
import org.apache.beam.sdk.util.Sleeper;
import org.apache.iceberg.Schema;
-import org.apache.iceberg.SchemaParser;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableProperties;
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;
import org.apache.iceberg.types.TypeUtil;
-import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.PropertyUtil;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.joda.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * 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.
+ * Applies the distinct file schemas of a window to the table in one commit: computes the {@link
+ * SchemaPlan} against a fresh load of the table, {@code replay}s the planned union onto the real
+ * transaction as one schema update, repairs the name mapping, commits once. The table gains a
+ * single schema version per window; nothing is committed when nothing changes. A dry run reports
+ * the same plan instead of committing it.
*
- * 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.
+ *
When the table does not exist, {@code create} builds it from the planned union instead, with
+ * pinned columns and their ancestors required.
*
*
Incompatible schemas either fail the whole call before any commit ({@link
* IncompatibleSchemaHandling#FAIL_PIPELINE}) or are skipped so their files reach the error output
@@ -72,18 +67,31 @@
final class CommitSchemaUnion {
private static final Logger LOG = LoggerFactory.getLogger(CommitSchemaUnion.class);
- static final int MAX_ATTEMPTS = 5;
+ /**
+ * How long a commit or a plan keeps retrying, unless the table's own {@code commit.retry.*}
+ * properties say otherwise. More patient than Iceberg's defaults because Iceberg cannot retry
+ * these commits itself: a schema update inside a transaction cannot be re-applied after a
+ * concurrent commit of any kind, a data append included, so every such commit lands here.
+ */
+ static final Duration DEFAULT_RETRY_MIN_WAIT = Duration.millis(250);
+
+ static final Duration DEFAULT_RETRY_MAX_WAIT = Duration.standardSeconds(10);
+ static final Duration DEFAULT_RETRY_TOTAL_TIMEOUT = Duration.standardMinutes(1);
+
+ /** Tells the runner a worker waiting out a busy catalog is throttled, not busy. */
+ private static final Counter throttledMillis =
+ Metrics.counter(Metrics.THROTTLE_TIME_NAMESPACE, Metrics.THROTTLE_TIME_COUNTER_NAME);
/** 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 {
+ /** What a table is created with when it does not exist yet; unused on an existing table. */
+ static final class NewTableSettings implements Serializable {
final @Nullable List partitionFields;
final @Nullable List sortFields;
final @Nullable Map properties;
- TableCreation(
+ NewTableSettings(
@Nullable List partitionFields,
@Nullable List sortFields,
@Nullable Map properties) {
@@ -93,6 +101,25 @@ static final class TableCreation implements Serializable {
}
}
+ /** How a schema commit behaves; the same for the commit and for a dry run of it. */
+ static final class Settings implements Serializable {
+ final SchemaEvolutionConfig config;
+
+ /** Resolved for the pipeline's mode; the config's own value may be unset. */
+ final IncompatibleSchemaHandling handling;
+
+ final NewTableSettings newTable;
+
+ Settings(
+ SchemaEvolutionConfig config,
+ IncompatibleSchemaHandling handling,
+ NewTableSettings newTable) {
+ this.config = config;
+ this.handling = handling;
+ this.newTable = newTable;
+ }
+ }
+
/** Injectable so tests can exercise the commit retry path. */
interface Committer extends Serializable {
void commit(Transaction txn);
@@ -107,52 +134,6 @@ static final class IncompatibleSchemaException extends IllegalStateException {
}
}
- 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;
- final String reason;
-
- Incompatible(String schemaJson, long files, String reason) {
- this.schemaJson = schemaJson;
- this.files = files;
- this.reason = reason;
- }
-
- @Override
- public String toString() {
- return files + " file(s) with schema " + truncate(schemaJson) + ": " + reason;
- }
- }
-
- /** Canonical JSON of a wide schema runs to hundreds of KB; the reason is what matters. */
- private static final int MAX_SCHEMA_JSON_CHARS = 1024;
-
- private static String truncate(String json) {
- if (json.length() <= MAX_SCHEMA_JSON_CHARS) {
- return json;
- }
- return json.substring(0, MAX_SCHEMA_JSON_CHARS)
- + "... ("
- + (json.length() - MAX_SCHEMA_JSON_CHARS)
- + " chars truncated)";
- }
-
private CommitSchemaUnion() {}
/**
@@ -165,157 +146,169 @@ static long commit(
Catalog catalog,
TableIdentifier tableId,
List schemas,
- SchemaEvolutionConfig config,
- IncompatibleSchemaHandling handling,
- TableCreation creation,
+ Settings settings,
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.*)
- // sit below this loop.
- BackOff backoff =
- FluentBackoff.DEFAULT
- .withMaxRetries(MAX_ATTEMPTS - 1)
- .withInitialBackoff(Duration.millis(100))
- .withMaxBackoff(Duration.standardSeconds(2))
- .backoff();
+ return commit(catalog, tableId, schemas, settings, committer, Sleeper.DEFAULT);
+ }
+
+ /** Test hook: the sleeper keeps the retry tests from waiting out the backoff. */
+ static long commit(
+ Catalog catalog,
+ TableIdentifier tableId,
+ List schemas,
+ Settings settings,
+ Committer committer,
+ Sleeper sleeper) {
+ return withRetry(
+ catalog,
+ tableId,
+ sleeper,
+ table -> commitOnce(catalog, tableId, table, schemas, settings, committer));
+ }
+
+ /** One attempt at a plan or a commit, against the table as it is now; null when missing. */
+ private interface Attempt {
+ T run(@Nullable Table table);
+ }
+
+ /**
+ * Runs an attempt again, against a fresh load of the table, when a concurrent commit or a create
+ * race invalidates the state it started from. Gives up when the backoff time is spent.
+ */
+ private static T withRetry(
+ Catalog catalog, TableIdentifier tableId, Sleeper sleeper, Attempt once) {
+ @Nullable BackOff backoff = null;
for (int attempt = 1; ; attempt++) {
+ @Nullable Table table;
+ try {
+ table = catalog.loadTable(tableId);
+ } catch (NoSuchTableException e) {
+ table = null;
+ }
try {
- return commitOnce(catalog, tableId, schemas, config, handling, creation, committer);
+ return once.run(table);
} catch (CommitFailedException | AlreadyExistsException e) {
- // a concurrent commit, or a create race: the next attempt loads the fresh state
+ if (backoff == null) {
+ backoff = retryBackoff(table).backoff();
+ }
+ LOG.info(
+ "Schema pre-pass attempt {} for {} failed: {}",
+ attempt,
+ tableId,
+ AddFiles.errorMessage(e));
try {
- if (!BackOffUtils.next(Sleeper.DEFAULT, backoff)) {
+ if (!BackOffUtils.next(sleeper, backoff)) {
throw e;
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw e;
}
- LOG.info(
- "Schema commit attempt {}/{} for {} failed; reloading and rebuilding",
- attempt,
- MAX_ATTEMPTS,
- tableId,
- e);
}
}
}
+ /**
+ * Jittered exponential backoff, bounded by time; a retry count only applies when the table sets
+ * one. Values the backoff would reject are clamped, so a bad table property cannot replace the
+ * commit failure with a configuration error.
+ */
+ private static FluentBackoff retryBackoff(@Nullable Table table) {
+ Map properties = table == null ? Collections.emptyMap() : table.properties();
+ long minWaitMillis =
+ PropertyUtil.propertyAsLong(
+ properties,
+ TableProperties.COMMIT_MIN_RETRY_WAIT_MS,
+ DEFAULT_RETRY_MIN_WAIT.getMillis());
+ long maxWaitMillis =
+ PropertyUtil.propertyAsLong(
+ properties,
+ TableProperties.COMMIT_MAX_RETRY_WAIT_MS,
+ DEFAULT_RETRY_MAX_WAIT.getMillis());
+ long totalMillis =
+ PropertyUtil.propertyAsLong(
+ properties,
+ TableProperties.COMMIT_TOTAL_RETRY_TIME_MS,
+ DEFAULT_RETRY_TOTAL_TIMEOUT.getMillis());
+ int maxRetries =
+ PropertyUtil.propertyAsInt(
+ properties, TableProperties.COMMIT_NUM_RETRIES, Integer.MAX_VALUE);
+ return FluentBackoff.DEFAULT
+ .withExponent(2.0)
+ .withInitialBackoff(Duration.millis(Math.max(1, minWaitMillis)))
+ .withMaxBackoff(Duration.millis(Math.max(1, maxWaitMillis)))
+ .withMaxCumulativeBackoff(Duration.millis(Math.max(1, totalMillis)))
+ .withMaxRetries(Math.max(0, maxRetries))
+ .withThrottledTimeCounter(throttledMillis);
+ }
+
+ /** The plan for the window's schemas, reloading the table when a concurrent change moves it. */
+ static SchemaPlan plan(
+ Catalog catalog,
+ TableIdentifier tableId,
+ List schemas,
+ Settings settings) {
+ return withRetry(
+ catalog,
+ tableId,
+ Sleeper.DEFAULT,
+ table -> SchemaPlan.compute(catalog, tableId, table, schemas, settings));
+ }
+
private static long commitOnce(
Catalog catalog,
TableIdentifier tableId,
+ @Nullable Table table,
List schemas,
- SchemaEvolutionConfig config,
- IncompatibleSchemaHandling handling,
- TableCreation creation,
+ Settings settings,
Committer committer) {
- Table table;
- try {
- table = catalog.loadTable(tableId);
- } catch (NoSuchTableException e) {
- return create(catalog, tableId, schemas, config, handling, creation, committer);
+ SchemaPlan plan = SchemaPlan.compute(catalog, tableId, table, schemas, settings);
+ if (plan instanceof SchemaPlan.Creation) {
+ return create((SchemaPlan.Creation) plan, catalog, tableId, settings, 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();
+ return evolve((SchemaPlan.Evolution) plan, tableId, settings.handling, committer);
+ }
- List incompatible = new ArrayList<>();
- List accepted = classify(table, schemas, config, incompatible);
- Schema merged = fold(table, base, tableId, accepted, incompatible);
+ /** Replays the planned union onto the table and repairs the name mapping, in one commit. */
+ private static long evolve(
+ SchemaPlan.Evolution plan,
+ TableIdentifier tableId,
+ IncompatibleSchemaHandling handling,
+ Committer committer) {
+ failOrWarnOnIncompatibleSchemas(
+ tableId, plan.incompatibleSchemas, handling, "no schema change was committed");
+ failOrWarnOnConfigProblems(tableId, plan.configProblems, handling);
- Transaction txn = newTransactionOn(table, base, tableId);
- if (merged != null) {
- replay(txn, merged, tableId);
+ Transaction txn = plan.newTransaction(tableId);
+ if (plan.newSchema != null) {
+ replay(txn, plan.newSchema, tableId);
}
- boolean staged = merged != null;
- staged |= stageNameMapping(txn);
-
- if (!incompatible.isEmpty()) {
- reportIncompatible(tableId, incompatible, handling, "no schema change was committed");
+ if (plan.repairsNameMapping) {
+ stageNameMapping(txn);
}
-
- if (!staged) {
- LOG.info(
- "Table {} already covers all {} file schema(s); nothing to commit",
- tableId,
- schemas.size());
- return table.schema().schemaId();
+ if (plan.newSchema == null && !plan.repairsNameMapping) {
+ LOG.info("Table {} already covers every file schema; nothing to commit", tableId);
+ return plan.base.schemaId();
}
committer.commit(txn);
long schemaId = txn.table().schema().schemaId();
- long acceptedFiles = 0;
- for (Accepted item : accepted) {
- acceptedFiles += item.files;
+ long mergedFiles = 0;
+ for (SchemaPlan.SchemaToMerge item : plan.schemasToMerge) {
+ mergedFiles += item.files;
}
LOG.info(
"Committed schema union for {}: {} schema(s) covering {} file(s), now at schema id {}",
tableId,
- accepted.size(),
- acceptedFiles,
+ plan.schemasToMerge.size(),
+ mergedFiles,
schemaId);
return schemaId;
}
- /**
- * 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 classify(
- Table table,
- List schemas,
- SchemaEvolutionConfig config,
- List incompatible) {
- List 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;
- }
-
- /**
- * 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 @Nullable Schema fold(
- Table table,
- Schema base,
- TableIdentifier tableId,
- List accepted,
- List incompatible) {
- while (true) {
- 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.
+ * detector: concurrent changes are caught earlier, when the plan opens its transaction.
*/
private static void replay(Transaction txn, Schema merged, TableIdentifier tableId) {
txn.updateSchema().unionByNameWith(merged).commit();
@@ -338,192 +331,59 @@ private static void replay(Transaction txn, Schema merged, TableIdentifier table
* append after them.
*/
private static long create(
+ SchemaPlan.Creation plan,
Catalog catalog,
TableIdentifier tableId,
- List schemas,
- SchemaEvolutionConfig config,
- IncompatibleSchemaHandling handling,
- TableCreation creation,
+ Settings settings,
Committer committer) {
- if (schemas.isEmpty()) {
+ if (plan.schemasToMerge.isEmpty() && plan.incompatibleSchemas.isEmpty()) {
LOG.info("Table {} does not exist and no file schema was read; not creating it", tableId);
return NO_TABLE;
}
- List 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) {
+ failOrWarnOnIncompatibleSchemas(
+ tableId, plan.incompatibleSchemas, settings.handling, "no table was created");
+ if (plan.newSchema == 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 properties =
- creation.properties == null ? new HashMap<>() : new HashMap<>(creation.properties);
+ if (plan.problem != null) {
+ throw new IllegalStateException(plan.problem);
+ }
+ failOrWarnOnConfigProblems(tableId, plan.configProblems, settings.handling);
+ Map properties = new HashMap<>();
+ if (settings.newTable.properties != null) {
+ properties.putAll(settings.newTable.properties);
+ }
Transaction txn =
catalog
- .buildTable(tableId, created)
- .withPartitionSpec(PartitionUtils.toPartitionSpec(creation.partitionFields, created))
- .withSortOrder(SortOrderUtils.toSortOrder(creation.sortFields, created))
+ .buildTable(tableId, plan.newSchema)
+ .withPartitionSpec(checkStateNotNull(plan.spec))
+ .withSortOrder(checkStateNotNull(plan.sortOrder))
.withProperties(properties)
.createTransaction();
- stageNameMapping(txn);
+ if (SchemaPlan.needsNameMapping(txn.table().properties(), txn.table().schema())) {
+ 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(),
+ plan.schemasToMerge.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 schemas,
- List 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 valid = new ArrayList<>();
- for (CollectDistinctSchemas.SchemaGroup group : schemas) {
- Schema fileSchema = SchemaParser.fromJson(group.getSchemaJson());
- List 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 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 scratch.table().schema();
- }
- 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 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 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 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(
+ private static void failOrWarnOnIncompatibleSchemas(
TableIdentifier tableId,
- List incompatible,
+ List incompatible,
IncompatibleSchemaHandling handling,
String consequence) {
+ if (incompatible.isEmpty()) {
+ return;
+ }
long files = 0;
- for (Incompatible item : incompatible) {
+ for (SchemaPlan.IncompatibleSchema item : incompatible) {
files += item.files;
}
if (handling == IncompatibleSchemaHandling.FAIL_PIPELINE) {
@@ -548,144 +408,33 @@ private static void reportIncompatible(
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, List 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 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 changes) {
- List 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
- * against is thrown as {@link CommitFailedException} so the commit-level retry reloads and
- * rebuilds, leaving the replay checkState as a pure bug detector.
- */
- private static Transaction newTransactionOn(Table table, Schema base, TableIdentifier tableId) {
- Transaction txn = table.newTransaction();
- if (!txn.table().schema().sameSchema(base)) {
- throw new CommitFailedException(
- "concurrent schema change on %s while staging the schema union", tableId);
- }
- return txn;
- }
-
- private static void stage(Transaction txn, Accepted item) {
- UpdateSchema update = txn.updateSchema().unionByNameWith(item.schema);
- if (item.delta != null) {
- for (String path : item.delta.absentRequiredPaths()) {
- update = update.makeColumnOptional(path);
- }
- }
- update.commit();
- }
-
- /**
- * New columns are optional at every level. The union adds top-level columns optional but keeps
- * the file's optionality below them, so one file's luck would otherwise impose required fields on
- * everyone. Pins do not shape new columns: they keep existing required columns from being relaxed
- * (SchemaDelta) and gate files at registration.
- */
- private static void relaxNewRequiredFields(Transaction txn, Schema before) {
- List toRelax = newRequiredPaths(before, txn.table().schema());
- if (toRelax.isEmpty()) {
+ /** A configuration problem fails the run under FAIL_PIPELINE and warns under ROUTE_TO_ERRORS. */
+ private static void failOrWarnOnConfigProblems(
+ TableIdentifier tableId, List problems, IncompatibleSchemaHandling handling) {
+ if (problems.isEmpty()) {
return;
}
- UpdateSchema update = txn.updateSchema();
- for (String path : toRelax) {
- update = update.makeColumnOptional(path);
+ if (handling == IncompatibleSchemaHandling.FAIL_PIPELINE) {
+ throw new IncompatibleSchemaException(String.join("; ", problems));
}
- update.commit();
- }
-
- /**
- * Paths of required fields that {@code after} has and {@code before} lacks, in schema order;
- * includes fields under lists and maps (a required list element or map value counts). Map key
- * subtrees are skipped: keys are required by definition and relaxing inside a struct key would
- * change key identity.
- */
- static List newRequiredPaths(Schema before, Schema after) {
- Set beforeIds = TypeUtil.indexById(before.asStruct()).keySet();
- List paths = new ArrayList<>();
- collectNewRequired(after.asStruct(), "", beforeIds, paths);
- return paths;
- }
-
- private static void collectNewRequired(
- Type.NestedType type, String prefix, Set beforeIds, List paths) {
- for (Types.NestedField field : type.fields()) {
- if (type.isMapType() && field.fieldId() == type.asMapType().keyId()) {
- continue;
- }
- String path = prefix + field.name();
- if (!beforeIds.contains(field.fieldId()) && field.isRequired()) {
- paths.add(path);
- }
- if (field.type().isNestedType()) {
- collectNewRequired(field.type().asNestedType(), path + ".", beforeIds, paths);
- }
+ for (String problem : problems) {
+ LOG.warn("Configuration problem on {}: {}", tableId, problem);
}
}
- /** Regenerates the name mapping when absent, malformed or not covering the staged schema. */
- private static boolean stageNameMapping(Transaction txn) {
+ /** Regenerates the name mapping property for the transaction's schema. */
+ private static void stageNameMapping(Transaction txn) {
Schema schema = txn.table().schema();
@Nullable NameMapping existing =
NameMappingUtils.parseOrNull(
txn.table().properties().get(TableProperties.DEFAULT_NAME_MAPPING));
- if (existing != null && NameMappingUtils.covers(existing, schema.asStruct())) {
- return false;
- }
String regenerated = NameMappingUtils.regenerate(schema, existing);
txn.updateProperties().set(TableProperties.DEFAULT_NAME_MAPPING, regenerated).commit();
- return true;
}
- private static String joinLines(List items) {
+ private static String joinLines(List items) {
List lines = new ArrayList<>();
- for (Incompatible item : items) {
+ for (SchemaPlan.IncompatibleSchema item : items) {
lines.add(item.toString());
}
return String.join("\n ", lines);
diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/DryRunReport.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/DryRunReport.java
new file mode 100644
index 000000000000..ae069598e0a6
--- /dev/null
+++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/DryRunReport.java
@@ -0,0 +1,434 @@
+/*
+ * 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.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.IncompatibleSchemaHandling;
+import org.apache.beam.sdk.metrics.Counter;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.hash.Hashing;
+import org.apache.iceberg.SchemaParser;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.types.Type;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Dry run of the schema pre-pass: computes the {@link CommitSchemaUnion#plan} a real commit would,
+ * on scratch transactions only, and reports it without committing or registering anything. Planning
+ * the real fold rather than classifying each schema alone is what makes a schema that is fine
+ * against the table but conflicts with another schema of the input come out with the blame a real
+ * run would assign.
+ *
+ * One row per window. {@code allowed} is true when every file schema can be merged and the
+ * configuration raises no problem; otherwise {@code reason} says what a real run would do about it.
+ * {@code schemas} holds one entry per distinct file schema with the changes a real run would make
+ * on an existing table ({@code schema_key} is a short hash to group by) and, when the schema cannot
+ * be merged, why. {@code created_table} is the table a real run would create from the union of the
+ * allowed schemas, absent when the table exists or no schema can seed it; {@code
+ * would_create_table} is false whenever a real run would not create it, including when it would
+ * fail first. When the table does not exist and no file schema can create it, nothing is allowed: a
+ * real run sends every file to the error output. {@code table_changes} lists what a real run
+ * changes without a schema change, such as regenerating the name mapping.
+ *
+ *
The file counts and the counters count checked Parquet files by whether their schema is
+ * allowed. Unreadable files always go to the error output in a real run; unchecked (ORC, Avro)
+ * files count separately even when {@code unchecked_registered} says ACCEPT registers them. Pin
+ * evidence is per file (the footer's null counts), so a pin violation or an unproven pin is not
+ * predicted here.
+ */
+class DryRunReport extends DoFn, Row> {
+ private static final Logger LOG = LoggerFactory.getLogger(DryRunReport.class);
+
+ static final String FILES_ALLOWED_COUNTER = "numDryRunFilesAllowed";
+ static final String FILES_INCOMPATIBLE_COUNTER = "numDryRunFilesIncompatible";
+ static final String FILES_UNREADABLE_COUNTER = "numDryRunFilesUnreadable";
+ static final String FILES_UNCHECKED_COUNTER = "numDryRunFilesUnchecked";
+ static final String CONFIG_PROBLEMS_COUNTER = "numDryRunConfigProblems";
+
+ private static final Counter numFilesAllowed = counter(DryRunReport.class, FILES_ALLOWED_COUNTER);
+ private static final Counter numFilesIncompatible =
+ counter(DryRunReport.class, FILES_INCOMPATIBLE_COUNTER);
+ private static final Counter numFilesUnreadable =
+ counter(DryRunReport.class, FILES_UNREADABLE_COUNTER);
+ private static final Counter numFilesUnchecked =
+ counter(DryRunReport.class, FILES_UNCHECKED_COUNTER);
+ private static final Counter numConfigProblems =
+ counter(DryRunReport.class, CONFIG_PROBLEMS_COUNTER);
+
+ static final Schema SCHEMA_ENTRY =
+ Schema.builder()
+ .addStringField("schema_key")
+ .addStringField("schema")
+ .addInt64Field("num_files")
+ .addArrayField("changes", Schema.FieldType.STRING)
+ .addBooleanField("allowed")
+ .addStringField("reason")
+ .build();
+
+ static final Schema CREATED_TABLE =
+ Schema.builder()
+ .addStringField("schema")
+ .addArrayField("columns", Schema.FieldType.STRING)
+ .build();
+
+ static final Schema REPORT_SCHEMA =
+ Schema.builder()
+ .addBooleanField("allowed")
+ .addStringField("reason")
+ .addBooleanField("would_create_table")
+ .addInt64Field("files_allowed")
+ .addInt64Field("files_incompatible")
+ .addInt64Field("files_unreadable")
+ .addInt64Field("files_unchecked")
+ .addBooleanField("unchecked_registered")
+ .addArrayField("table_changes", Schema.FieldType.STRING)
+ .addArrayField("config_problems", Schema.FieldType.STRING)
+ .addNullableRowField("created_table", CREATED_TABLE)
+ .addArrayField("schemas", Schema.FieldType.row(SCHEMA_ENTRY))
+ .build();
+
+ static final String NAME_MAPPING_CHANGE =
+ "regenerate the name mapping property to cover the schema";
+
+ static final String NO_TABLE_REASON =
+ "the table does not exist and no file schema can create it; a real run sends every file to"
+ + " the error output";
+
+ /** Wide inputs would otherwise put every column of every schema into one log entry. */
+ private static final int MAX_RENDERED_ENTRIES = 50;
+
+ private static final int MAX_RENDERED_CHANGES = 10;
+
+ private final IcebergCatalogConfig catalogConfig;
+ private final String identifier;
+ private final CommitSchemaUnion.Settings settings;
+ private transient @MonotonicNonNull Catalog catalog;
+
+ DryRunReport(
+ IcebergCatalogConfig catalogConfig, String identifier, CommitSchemaUnion.Settings settings) {
+ this.catalogConfig = catalogConfig;
+ this.identifier = identifier;
+ this.settings = settings;
+ }
+
+ /** One {@code schemas} entry before it is a Row: the totals and the rendered log read these. */
+ private static final class SchemaEntry {
+ final String key;
+ final String schema;
+ final long files;
+ final List changes;
+ final boolean allowed;
+ final String reason;
+
+ SchemaEntry(
+ String key,
+ String schema,
+ long files,
+ List changes,
+ boolean allowed,
+ String reason) {
+ this.key = key;
+ this.schema = schema;
+ this.files = files;
+ this.changes = changes;
+ this.allowed = allowed;
+ this.reason = reason;
+ }
+
+ Row toRow() {
+ return Row.withSchema(SCHEMA_ENTRY)
+ .withFieldValue("schema_key", key)
+ .withFieldValue("schema", schema)
+ .withFieldValue("num_files", files)
+ .withFieldValue("changes", changes)
+ .withFieldValue("allowed", allowed)
+ .withFieldValue("reason", reason)
+ .build();
+ }
+ }
+
+ /** The window's schema groups: the ones the plan sees, and the files that contribute none. */
+ private static final class Input {
+ final List readable = new ArrayList<>();
+ long unreadableFiles;
+ long uncheckedFiles;
+
+ static Input of(List schemas) {
+ Input input = new Input();
+ for (CollectDistinctSchemas.SchemaGroup group : schemas) {
+ if (group.getSchemaJson().equals(ReadFooterSchema.UNREADABLE_KEY)) {
+ input.unreadableFiles += group.getFiles();
+ } else if (group.getSchemaJson().equals(ReadFooterSchema.UNCHECKED_FORMAT_KEY)) {
+ input.uncheckedFiles += group.getFiles();
+ } else {
+ input.readable.add(group);
+ }
+ }
+ return input;
+ }
+ }
+
+ private static final class Totals {
+ int allowedSchemas;
+ long allowedFiles;
+ int incompatibleSchemas;
+ long incompatibleFiles;
+
+ static Totals of(List entries) {
+ Totals totals = new Totals();
+ for (SchemaEntry entry : entries) {
+ if (entry.allowed) {
+ totals.allowedSchemas++;
+ totals.allowedFiles += entry.files;
+ } else {
+ totals.incompatibleSchemas++;
+ totals.incompatibleFiles += entry.files;
+ }
+ }
+ return totals;
+ }
+ }
+
+ @ProcessElement
+ public void process(
+ @Element List schemas, OutputReceiver out) {
+ if (catalog == null) {
+ catalog = catalogConfig.catalog();
+ }
+ TableIdentifier tableId = IcebergUtils.parseTableIdentifier(identifier);
+ Input input = Input.of(schemas);
+ SchemaPlan plan = CommitSchemaUnion.plan(catalog, tableId, input.readable, settings);
+
+ List entries = new ArrayList<>();
+ for (CollectDistinctSchemas.SchemaGroup group : input.readable) {
+ entries.add(schemaEntry(group, plan));
+ }
+ Totals totals = Totals.of(entries);
+
+ SchemaPlan.@Nullable Creation creation = null;
+ if (plan instanceof SchemaPlan.Creation) {
+ creation = (SchemaPlan.Creation) plan;
+ }
+ @Nullable String creationProblem = creation == null ? null : creation.problem;
+ long files =
+ totals.allowedFiles
+ + totals.incompatibleFiles
+ + input.unreadableFiles
+ + input.uncheckedFiles;
+ boolean noTable = creation != null && creation.newSchema == null && files > 0;
+ boolean allowed =
+ totals.incompatibleSchemas == 0
+ && plan.configProblems.isEmpty()
+ && creationProblem == null
+ && !noTable;
+ boolean wouldFail =
+ creationProblem != null
+ || (settings.handling == IncompatibleSchemaHandling.FAIL_PIPELINE
+ && (totals.incompatibleSchemas > 0 || !plan.configProblems.isEmpty()));
+ boolean wouldCreateTable = creation != null && creation.canCreate() && !wouldFail;
+ String consequence = consequence(totals, plan.configProblems, creationProblem, noTable);
+
+ List configProblems = new ArrayList<>(plan.configProblems);
+ if (creationProblem != null) {
+ configProblems.add(creationProblem);
+ }
+ List tableChanges = new ArrayList<>();
+ if (plan instanceof SchemaPlan.Evolution && ((SchemaPlan.Evolution) plan).repairsNameMapping) {
+ tableChanges.add(NAME_MAPPING_CHANGE);
+ }
+ boolean uncheckedRegistered =
+ settings.config.getUnverifiableFileHandling()
+ == SchemaEvolutionConfig.UnverifiableFileHandling.ACCEPT
+ && !noTable;
+ List entryRows = new ArrayList<>();
+ for (SchemaEntry entry : entries) {
+ entryRows.add(entry.toRow());
+ }
+
+ Row.FieldValueBuilder report =
+ Row.withSchema(REPORT_SCHEMA)
+ .withFieldValue("allowed", allowed)
+ .withFieldValue("reason", consequence)
+ .withFieldValue("would_create_table", wouldCreateTable)
+ .withFieldValue("files_allowed", totals.allowedFiles)
+ .withFieldValue("files_incompatible", totals.incompatibleFiles)
+ .withFieldValue("files_unreadable", input.unreadableFiles)
+ .withFieldValue("files_unchecked", input.uncheckedFiles)
+ .withFieldValue("unchecked_registered", uncheckedRegistered)
+ .withFieldValue("table_changes", tableChanges)
+ .withFieldValue("config_problems", configProblems)
+ .withFieldValue("schemas", entryRows);
+ List createdColumns = Collections.emptyList();
+ if (creation != null && creation.newSchema != null) {
+ createdColumns = createdColumns(creation.newSchema);
+ report =
+ report.withFieldValue(
+ "created_table",
+ Row.withSchema(CREATED_TABLE)
+ .withFieldValue("schema", SchemaParser.toJson(creation.newSchema))
+ .withFieldValue("columns", createdColumns)
+ .build());
+ }
+ out.output(report.build());
+
+ numFilesAllowed.inc(totals.allowedFiles);
+ numFilesIncompatible.inc(totals.incompatibleFiles);
+ numFilesUnreadable.inc(input.unreadableFiles);
+ numFilesUnchecked.inc(input.uncheckedFiles);
+ numConfigProblems.inc(configProblems.size());
+ LOG.info(
+ "Dry run for {}{}: {}{}{}\n{}",
+ identifier,
+ wouldCreateTable ? " (table would be created)" : "",
+ summary(input, totals),
+ consequence.isEmpty() ? "" : "; " + consequence,
+ tableChanges.isEmpty() ? "" : "; " + String.join("; ", tableChanges),
+ render(entries, createdColumns));
+ }
+
+ private SchemaEntry schemaEntry(CollectDistinctSchemas.SchemaGroup group, SchemaPlan plan) {
+ String json = group.getSchemaJson();
+ @Nullable String incompatibleReason = plan.incompatibleReason(json);
+ // a created table's columns are reported once, as created_table: no delta exists then
+ List changes = Collections.emptyList();
+ SchemaPlan.@Nullable SchemaToMerge toMerge = plan.toMerge(json);
+ if (toMerge != null && toMerge.delta != null) {
+ changes = toMerge.delta.descriptions();
+ }
+ return new SchemaEntry(
+ key(json),
+ json,
+ group.getFiles(),
+ changes,
+ incompatibleReason == null,
+ incompatibleReason == null ? "" : incompatibleReason);
+ }
+
+ private static String summary(Input input, Totals totals) {
+ return String.format(
+ "%d distinct schemas; %d allowed covering %d files; %d incompatible covering %d files;"
+ + " %d files unreadable; %d files unchecked (ORC or Avro)",
+ input.readable.size(),
+ totals.allowedSchemas,
+ totals.allowedFiles,
+ totals.incompatibleSchemas,
+ totals.incompatibleFiles,
+ input.unreadableFiles,
+ input.uncheckedFiles);
+ }
+
+ private String consequence(
+ Totals totals,
+ List configProblems,
+ @Nullable String creationProblem,
+ boolean noTable) {
+ IncompatibleSchemaHandling handling = settings.handling;
+ List parts = new ArrayList<>();
+ if (creationProblem != null) {
+ parts.add("a real run would fail to create the table: " + creationProblem);
+ }
+ if (noTable) {
+ parts.add(NO_TABLE_REASON);
+ }
+ if (totals.incompatibleSchemas > 0) {
+ parts.add(
+ handling == IncompatibleSchemaHandling.FAIL_PIPELINE
+ ? "a real run would fail before committing (" + handling + ")"
+ : "a real run would route "
+ + totals.incompatibleFiles
+ + " files to errors ("
+ + handling
+ + ")");
+ }
+ if (!configProblems.isEmpty()) {
+ parts.add(
+ (handling == IncompatibleSchemaHandling.FAIL_PIPELINE
+ ? "a real run would fail on the configuration: "
+ : "a real run would warn on the configuration: ")
+ + String.join("; ", configProblems));
+ }
+ return String.join("; ", parts);
+ }
+
+ private static String render(List entries, List createdColumns) {
+ StringBuilder rendered = new StringBuilder();
+ for (SchemaEntry entry : entries.subList(0, Math.min(entries.size(), MAX_RENDERED_ENTRIES))) {
+ rendered.append(
+ String.format(
+ " %-7s %8d allowed=%-5s %s %s%n",
+ entry.key, entry.files, entry.allowed, cut(entry.changes), entry.reason));
+ }
+ if (entries.size() > MAX_RENDERED_ENTRIES) {
+ rendered
+ .append(" ... and ")
+ .append(entries.size() - MAX_RENDERED_ENTRIES)
+ .append(" more schemas\n");
+ }
+ if (!createdColumns.isEmpty()) {
+ rendered.append(" created table: ").append(cut(createdColumns)).append('\n');
+ }
+ return rendered.toString();
+ }
+
+ private static List cut(List changes) {
+ if (changes.size() <= MAX_RENDERED_CHANGES) {
+ return changes;
+ }
+ List shown = new ArrayList<>(changes.subList(0, MAX_RENDERED_CHANGES));
+ shown.add("... and " + (changes.size() - MAX_RENDERED_CHANGES) + " more");
+ return shown;
+ }
+
+ /** Top-level columns of the table a real run would create; nested detail is in the schema. */
+ private static List createdColumns(org.apache.iceberg.Schema created) {
+ List columns = new ArrayList<>();
+ for (org.apache.iceberg.types.Types.NestedField field : created.columns()) {
+ columns.add(
+ "create "
+ + (field.isOptional() ? "optional " : "required ")
+ + field.name()
+ + " "
+ + typeLabel(field.type()));
+ }
+ return columns;
+ }
+
+ /** Nested types print field ids, which a creation reassigns, so only their kind is named. */
+ private static String typeLabel(Type type) {
+ if (type.isPrimitiveType()) {
+ return type.toString();
+ }
+ return type.typeId().name().toLowerCase(Locale.ROOT);
+ }
+
+ static String key(String schemaJson) {
+ return "s"
+ + Hashing.murmur3_32_fixed().hashUnencodedChars(schemaJson).toString().substring(0, 6);
+ }
+}
diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java
index e559feab3512..389f03490fe4 100644
--- a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java
+++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java
@@ -52,15 +52,25 @@ class ReadFooterSchema extends DoFn
private static final Counter numFooterReadErrors =
counter(ReadFooterSchema.class, FOOTER_READ_ERRORS_COUNTER);
+ /**
+ * Emitted in a dry run for files that contribute no schema. The key travels in the group's schema
+ * JSON field, which the combine groups by, so the files of one kind add up without a second path.
+ */
+ static final String UNREADABLE_KEY = "unread";
+
+ static final String UNCHECKED_FORMAT_KEY = "unchecked";
+
+ private final SchemaEvolutionConfig config;
private final int threadPoolSize;
private final int maxInFlightTasks;
private transient @MonotonicNonNull BoundedAsyncTasks tasks;
- ReadFooterSchema() {
- this(DEFAULT_THREAD_POOL_SIZE, DEFAULT_MAX_IN_FLIGHT_TASKS);
+ ReadFooterSchema(SchemaEvolutionConfig config) {
+ this(config, DEFAULT_THREAD_POOL_SIZE, DEFAULT_MAX_IN_FLIGHT_TASKS);
}
- ReadFooterSchema(int threadPoolSize, int maxInFlightTasks) {
+ ReadFooterSchema(SchemaEvolutionConfig config, int threadPoolSize, int maxInFlightTasks) {
+ this.config = config;
this.threadPoolSize = threadPoolSize;
this.maxInFlightTasks = maxInFlightTasks;
}
@@ -148,17 +158,18 @@ private static void count(ReadResult result) {
}
}
- private static Callable createReadTask(
+ private Callable createReadTask(
String filePath, Instant timestamp, BoundedWindow window, PaneInfo paneInfo) {
return () -> {
FileFormat format;
try {
format = AddFiles.inferFormat(filePath);
} catch (AddFiles.UnknownFormatException e) {
- return new ReadResult(null, false, timestamp, window, paneInfo);
+ return new ReadResult(dryRunMarker(UNREADABLE_KEY), false, timestamp, window, paneInfo);
}
if (!format.equals(FileFormat.PARQUET)) {
- return new ReadResult(null, false, timestamp, window, paneInfo);
+ return new ReadResult(
+ dryRunMarker(UNCHECKED_FORMAT_KEY), false, timestamp, window, paneInfo);
}
try {
ParquetMetadata footer = ParquetFooters.read(filePath);
@@ -168,8 +179,16 @@ private static Callable createReadTask(
"Could not read the footer of {}; the file will not contribute to schema inference: {}",
filePath,
AddFiles.errorMessage(e));
- return new ReadResult(null, true, timestamp, window, paneInfo);
+ return new ReadResult(dryRunMarker(UNREADABLE_KEY), true, timestamp, window, paneInfo);
}
};
}
+
+ /** A dry run counts files that contribute no schema; a real run handles them at registration. */
+ private CollectDistinctSchemas.@Nullable SchemaGroup dryRunMarker(String key) {
+ if (!config.getDryRun()) {
+ return null;
+ }
+ return CollectDistinctSchemas.SchemaGroup.of(key, 1, Collections.emptyList());
+ }
}
diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaDelta.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaDelta.java
index bacdac2e7cfe..743558e68f3c 100644
--- a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaDelta.java
+++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaDelta.java
@@ -60,7 +60,14 @@ private SchemaDelta(List changes) {
* (dotted, empty, case-colliding) come back as conflicts without attempting the union.
*/
static SchemaDelta classify(Table table, Schema fileSchema) {
- Schema before = table.schema();
+ return classify(table, table.schema(), fileSchema);
+ }
+
+ /**
+ * Classifies against {@code before}, the caller's snapshot of the table schema. The union itself
+ * is applied to the table's current metadata, which the caller keeps equal to the snapshot.
+ */
+ static SchemaDelta classify(Table table, Schema before, Schema fileSchema) {
if (before.sameSchema(fileSchema)) {
return new SchemaDelta(Collections.emptyList());
}
diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfig.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfig.java
index fcbfc906f3c0..da6fd33ac9d1 100644
--- a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfig.java
+++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfig.java
@@ -55,7 +55,23 @@
* conflicts with the table or with another file's schema. {@link IncompatibleSchemaHandling}
* decides whether that fails the pipeline before any schema commit (the batch default) or skips the
* schema so its files reach the error output (the streaming default). Files whose footer cannot be
- * read or converted always go to the error output and never fail the pipeline.
+ * read or converted always go to the error output and never fail the pipeline. When the table does
+ * not exist, the pre-pass creates it from the union of the file schemas; if no readable Parquet
+ * schema can seed it, nothing is created and every file goes to the error output.
+ *
+ * Dry run. Reports what a real run would do on the {@code dry_run_report} output, one row
+ * per window; nothing is committed or registered. {@code allowed} is true when every file schema
+ * can be merged and the configuration raises no problem; otherwise {@code reason} says what a real
+ * run would do about it. {@code schemas} holds one entry per distinct file schema with the changes
+ * a real run would make and, when the schema cannot be merged, the option or conflict to fix;
+ * {@code created_table} shows the table a real run would create. The report is a PCollection like
+ * any other, so attach a sink to keep it; it is also logged at INFO and the file counts are
+ * published as counters ({@code numDryRunFilesAllowed}, {@code numDryRunFilesIncompatible}, {@code
+ * numDryRunFilesUnreadable}, {@code numDryRunFilesUnchecked}, {@code numDryRunConfigProblems}).
+ * Adjust the settings, rerun until the report is allowed, then run for real with an error output
+ * attached. Against a missing table the dry run computes the union through the catalog's
+ * create-transaction API, which a REST catalog serves as a stage-create request: the credentials
+ * need table-create permission even though no table is created.
*/
@AutoValue
public abstract class SchemaEvolutionConfig implements Serializable {
@@ -104,6 +120,12 @@ public boolean isPinned(String columnPath) {
return getRequiredColumns().contains(columnPath);
}
+ /**
+ * Report what the pre-pass would do on the {@code dry_run_report} output, one row per window;
+ * commit and register nothing.
+ */
+ public abstract boolean getDryRun();
+
/**
* Unset resolves by mode: {@code FAIL_PIPELINE} in batch, {@code ROUTE_TO_ERRORS} in streaming.
*/
@@ -144,7 +166,8 @@ public static Builder builder() {
return new AutoValue_SchemaEvolutionConfig.Builder()
.setOptions(Collections.emptySet())
.setRequiredColumns(Collections.emptySet())
- .setUnverifiableFileHandling(UnverifiableFileHandling.REJECT);
+ .setUnverifiableFileHandling(UnverifiableFileHandling.REJECT)
+ .setDryRun(false);
}
@AutoValue.Builder
@@ -158,6 +181,8 @@ public abstract Builder setIncompatibleSchemaHandling(
public abstract Builder setUnverifiableFileHandling(UnverifiableFileHandling handling);
+ public abstract Builder setDryRun(boolean dryRun);
+
abstract SchemaEvolutionConfig autoBuild();
/** Any setting without an option would silently do nothing, so they are rejected. */
@@ -172,10 +197,11 @@ public SchemaEvolutionConfig build() {
Preconditions.checkArgument(
config.isEnabled()
|| (config.getRequiredColumns().isEmpty()
+ && !config.getDryRun()
&& config.getIncompatibleSchemaHandling() == null
&& config.getUnverifiableFileHandling() == UnverifiableFileHandling.REJECT),
- "required columns, incompatible schema handling and unverifiable file handling need at"
- + " least one schema evolution option");
+ "required columns, dry run, incompatible schema handling and unverifiable file"
+ + " handling need at least one schema evolution option");
return config;
}
}
diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaPlan.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaPlan.java
new file mode 100644
index 000000000000..e9e72199c82b
--- /dev/null
+++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SchemaPlan.java
@@ -0,0 +1,618 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SchemaParser;
+import org.apache.iceberg.SortOrder;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+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.CommitFailedException;
+import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.mapping.NameMapping;
+import org.apache.iceberg.types.Type;
+import org.apache.iceberg.types.TypeUtil;
+import org.apache.iceberg.types.Types;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * What one schema commit would do, as data: which distinct file schemas it merges into the table,
+ * which it refuses and why, and the schema the table ends up with. {@link #compute} works it out on
+ * scratch transactions that are never committed: {@code classify} each schema against the table
+ * (most common first), then {@code fold} the accepted ones into a single union, which keeps
+ * per-schema blame for cross-schema conflicts. {@link CommitSchemaUnion} executes the plan and the
+ * dry run reports it, so a check added here reaches both and the two cannot drift.
+ */
+abstract class SchemaPlan {
+ final List schemasToMerge;
+ final List incompatibleSchemas;
+
+ /** The union to replay, or the schema to create the table with; null when nothing changes. */
+ final @Nullable Schema newSchema;
+
+ /** Problems the configuration raises against the planned schema, as messages. */
+ final List configProblems;
+
+ private SchemaPlan(Verdicts verdicts, @Nullable Schema newSchema, List configProblems) {
+ this.schemasToMerge = verdicts.toMerge;
+ this.incompatibleSchemas = verdicts.incompatible;
+ this.newSchema = newSchema;
+ this.configProblems = configProblems;
+ }
+
+ /**
+ * The schema's entry when the table must change for it; null when the schema is incompatible or
+ * the table already covers it.
+ */
+ @Nullable SchemaToMerge toMerge(String schemaJson) {
+ for (SchemaToMerge item : schemasToMerge) {
+ if (item.json.equals(schemaJson)) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ /** Why the schema is incompatible; null when it is compatible. */
+ @Nullable String incompatibleReason(String schemaJson) {
+ for (IncompatibleSchema item : incompatibleSchemas) {
+ if (item.schemaJson.equals(schemaJson)) {
+ return item.reason;
+ }
+ }
+ return null;
+ }
+
+ /** The plan against an existing table. */
+ static final class Evolution extends SchemaPlan {
+ final Table table;
+
+ /** The table schema every decision was made against. */
+ final Schema base;
+
+ /** Whether the commit regenerates the name mapping property, schema change or not. */
+ final boolean repairsNameMapping;
+
+ private Evolution(
+ Table table,
+ Schema base,
+ Verdicts verdicts,
+ @Nullable Schema newSchema,
+ boolean repairsNameMapping,
+ List configProblems) {
+ super(verdicts, newSchema, configProblems);
+ this.table = table;
+ this.base = base;
+ this.repairsNameMapping = repairsNameMapping;
+ }
+
+ /** A transaction on the table, checked against the snapshot this plan reasoned about. */
+ Transaction newTransaction(TableIdentifier tableId) {
+ return transactionOn(table, base, tableId);
+ }
+ }
+
+ /** The plan when the table does not exist; {@code newSchema} is what it would be created with. */
+ static final class Creation extends SchemaPlan {
+ /** Set together with {@link #sortOrder} unless {@link #problem} is. */
+ final @Nullable PartitionSpec spec;
+
+ final @Nullable SortOrder sortOrder;
+
+ /** Why the table cannot be created as configured; fails a real run under either handling. */
+ final @Nullable String problem;
+
+ private Creation(
+ Verdicts verdicts,
+ @Nullable Schema newSchema,
+ @Nullable PartitionSpec spec,
+ @Nullable SortOrder sortOrder,
+ @Nullable String problem,
+ List configProblems) {
+ super(verdicts, newSchema, configProblems);
+ this.spec = spec;
+ this.sortOrder = sortOrder;
+ this.problem = problem;
+ }
+
+ /** No file schema is left to create the table from. */
+ static Creation nothingToCreate(Verdicts verdicts) {
+ return new Creation(verdicts, null, null, null, null, new ArrayList<>());
+ }
+
+ static Creation of(
+ Verdicts verdicts,
+ Schema created,
+ PartitionSpec spec,
+ SortOrder sortOrder,
+ List configProblems) {
+ return new Creation(verdicts, created, spec, sortOrder, null, configProblems);
+ }
+
+ /** The configured partition or sort fields do not fit the schema. */
+ static Creation blocked(
+ Verdicts verdicts, Schema created, String problem, List configProblems) {
+ return new Creation(verdicts, created, null, null, problem, configProblems);
+ }
+
+ /** There is a schema to create from, and the configured partition and sort fields fit it. */
+ boolean canCreate() {
+ return newSchema != null && problem == null;
+ }
+ }
+
+ /** One distinct file schema the table must change for, and may under the options. */
+ static final class SchemaToMerge {
+ 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;
+
+ SchemaToMerge(Schema schema, String json, long files, @Nullable SchemaDelta delta) {
+ this.schema = schema;
+ this.json = json;
+ this.files = files;
+ this.delta = delta;
+ }
+ }
+
+ /** One distinct file schema the commit refuses, with the reason. */
+ static final class IncompatibleSchema {
+ final String schemaJson;
+ final long files;
+ final String reason;
+
+ IncompatibleSchema(String schemaJson, long files, String reason) {
+ this.schemaJson = schemaJson;
+ this.files = files;
+ this.reason = reason;
+ }
+
+ @Override
+ public String toString() {
+ return files + " file(s) with schema " + truncate(schemaJson) + ": " + reason;
+ }
+ }
+
+ /** Canonical JSON of a wide schema runs to hundreds of KB; the reason is what matters. */
+ private static final int MAX_SCHEMA_JSON_CHARS = 1024;
+
+ private static String truncate(String json) {
+ if (json.length() <= MAX_SCHEMA_JSON_CHARS) {
+ return json;
+ }
+ return json.substring(0, MAX_SCHEMA_JSON_CHARS)
+ + "... ("
+ + (json.length() - MAX_SCHEMA_JSON_CHARS)
+ + " chars truncated)";
+ }
+
+ /** Where each distinct file schema of the window stands while the plan is worked out. */
+ private static final class Verdicts {
+ final List toMerge = new ArrayList<>();
+ final List incompatible = new ArrayList<>();
+
+ void refuse(CollectDistinctSchemas.SchemaGroup group, String reason) {
+ incompatible.add(new IncompatibleSchema(group.getSchemaJson(), group.getFiles(), reason));
+ }
+
+ void refuse(Conflict conflict) {
+ toMerge.remove(conflict.schema);
+ incompatible.add(
+ new IncompatibleSchema(conflict.schema.json, conflict.schema.files, conflict.reason));
+ }
+ }
+
+ /**
+ * A schema that fits the table on its own but not the union of the window: a column another
+ * schema of the window adds has the same name with a different type, or a name differing only in
+ * case. Classification checks each schema against the table alone, so this surfaces only while
+ * the union is staged.
+ */
+ private static final class Conflict {
+ final SchemaToMerge schema;
+ final String reason;
+
+ Conflict(SchemaToMerge schema, String reason) {
+ this.schema = schema;
+ this.reason = reason;
+ }
+ }
+
+ /**
+ * The plan for the window's schemas against the table as loaded ({@code null} when it does not
+ * exist). Nothing is committed; on a missing table the fold goes through the catalog's create
+ * transaction, which a REST catalog serves as a stage-create request.
+ *
+ * @param schemas the window's distinct schema groups, most common first
+ */
+ static SchemaPlan compute(
+ Catalog catalog,
+ TableIdentifier tableId,
+ @Nullable Table table,
+ List schemas,
+ CommitSchemaUnion.Settings settings) {
+ if (table == null) {
+ return planCreation(catalog, tableId, schemas, settings);
+ }
+ return planEvolution(table, tableId, schemas, settings.config);
+ }
+
+ private static Evolution planEvolution(
+ Table table,
+ TableIdentifier tableId,
+ List schemas,
+ SchemaEvolutionConfig config) {
+ Schema base = table.schema();
+ if (schemas.isEmpty()) {
+ // the pipeline never commits for a window without schemas; a direct caller gets the same
+ return new Evolution(table, base, new Verdicts(), null, false, new ArrayList<>());
+ }
+ Verdicts verdicts = classify(table, base, schemas, config);
+ @Nullable Schema newSchema = fold(table, base, tableId, verdicts);
+ Schema afterwards = newSchema != null ? newSchema : base;
+ boolean repairsNameMapping = needsNameMapping(table.properties(), afterwards);
+ return new Evolution(table, base, verdicts, newSchema, repairsNameMapping, new ArrayList<>());
+ }
+
+ private static Creation planCreation(
+ Catalog catalog,
+ TableIdentifier tableId,
+ List schemas,
+ CommitSchemaUnion.Settings settings) {
+ Verdicts verdicts = classifyForCreate(schemas);
+ @Nullable Schema union = foldForCreate(catalog, tableId, verdicts);
+ if (union == null) {
+ return Creation.nothingToCreate(verdicts);
+ }
+ Schema created = createdSchema(union, settings.config);
+ List configProblems = new ArrayList<>();
+ addPinProblems(tableId, created, settings.config, configProblems);
+ CommitSchemaUnion.NewTableSettings newTable = settings.newTable;
+ try {
+ PartitionSpec spec = PartitionUtils.toPartitionSpec(newTable.partitionFields, created);
+ SortOrder sortOrder = SortOrderUtils.toSortOrder(newTable.sortFields, created);
+ return Creation.of(verdicts, created, spec, sortOrder, configProblems);
+ } catch (IllegalArgumentException | ValidationException e) {
+ String problem =
+ "Table "
+ + tableId
+ + " cannot be created with partition fields "
+ + newTable.partitionFields
+ + " and sort fields "
+ + newTable.sortFields
+ + " on the union of the file schemas: "
+ + AddFiles.errorMessage(e);
+ return Creation.blocked(verdicts, created, problem, configProblems);
+ }
+ }
+
+ private static void addPinProblems(
+ TableIdentifier tableId,
+ Schema created,
+ SchemaEvolutionConfig config,
+ List configProblems) {
+ List unenforceable = unenforceablePins(created, config);
+ if (!unenforceable.isEmpty()) {
+ configProblems.add(
+ "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");
+ }
+ }
+
+ /**
+ * Sorts the window's schemas into the ones the table must change for ({@code toMerge}) and the
+ * ones it must not ({@code incompatible}, with the reason); schemas the table already covers drop
+ * out.
+ */
+ private static Verdicts classify(
+ Table table,
+ Schema base,
+ List schemas,
+ SchemaEvolutionConfig config) {
+ Verdicts verdicts = new Verdicts();
+ for (CollectDistinctSchemas.SchemaGroup group : schemas) {
+ Schema fileSchema =
+ FileSchemas.markRequired(
+ SchemaParser.fromJson(group.getSchemaJson()), group.getNullFreeColumns());
+ SchemaDelta delta = SchemaDelta.classify(table, base, fileSchema);
+ if (delta.isEmpty()) {
+ continue;
+ }
+ if (!delta.allowedBy(config)) {
+ verdicts.refuse(group, delta.disallowedReason(config));
+ continue;
+ }
+ verdicts.toMerge.add(
+ new SchemaToMerge(fileSchema, group.getSchemaJson(), group.getFiles(), delta));
+ }
+ return verdicts;
+ }
+
+ /**
+ * 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 @Nullable Schema fold(
+ Table table, Schema base, TableIdentifier tableId, Verdicts verdicts) {
+ while (!verdicts.toMerge.isEmpty()) {
+ Transaction scratch = transactionOn(table, base, tableId);
+ @Nullable Conflict conflict = stageAll(scratch, verdicts.toMerge);
+ if (conflict == null) {
+ relaxNewRequiredFields(scratch, base);
+ return scratch.table().schema();
+ }
+ verdicts.refuse(conflict);
+ }
+ return null;
+ }
+
+ /**
+ * 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.
+ */
+ private static Verdicts classifyForCreate(List schemas) {
+ Verdicts verdicts = new Verdicts();
+ for (CollectDistinctSchemas.SchemaGroup group : schemas) {
+ Schema fileSchema = SchemaParser.fromJson(group.getSchemaJson());
+ List invalidNames = new ArrayList<>();
+ ColumnNameChecks.findInvalidNames(fileSchema.asStruct(), "", invalidNames);
+ if (!invalidNames.isEmpty()) {
+ verdicts.refuse(
+ group, "file schema has column names no table can hold: " + describe(invalidNames));
+ continue;
+ }
+ verdicts.toMerge.add(
+ new SchemaToMerge(fileSchema, group.getSchemaJson(), group.getFiles(), null));
+ }
+ return verdicts;
+ }
+
+ /**
+ * Unions the accepted schemas into one on scratch create transactions that are never committed,
+ * seeded by the most common schema; a schema that conflicts with the others moves to {@code
+ * incompatible}, leaves {@code toMerge}, and the fold restarts without it. Returns the union, or
+ * null when no schema is left to create from. A REST catalog serves each create transaction as a
+ * stage-create request, so the caller needs table-create permission even in a dry run.
+ */
+ private static @Nullable Schema foldForCreate(
+ Catalog catalog, TableIdentifier tableId, Verdicts verdicts) {
+ List toMerge = verdicts.toMerge;
+ while (!toMerge.isEmpty()) {
+ Schema seed = toMerge.get(0).schema;
+ Transaction scratch = catalog.buildTable(tableId, seed).createTransaction();
+ @Nullable Conflict conflict = stageAll(scratch, toMerge.subList(1, toMerge.size()));
+ if (conflict == null) {
+ return scratch.table().schema();
+ }
+ verdicts.refuse(conflict);
+ }
+ return null;
+ }
+
+ /**
+ * Pins 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). Such a pin would stay inert forever,
+ * since later windows only add columns optional, so the plan reports it as a configuration
+ * problem.
+ */
+ static List unenforceablePins(Schema created, SchemaEvolutionConfig config) {
+ List unenforceable = new ArrayList<>();
+ for (String pin : config.getRequiredColumns()) {
+ Types.NestedField field = created.findField(pin);
+ if (field == null || field.isOptional()) {
+ unenforceable.add(pin);
+ }
+ }
+ Collections.sort(unenforceable);
+ return unenforceable;
+ }
+
+ /**
+ * 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 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 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;
+ }
+
+ /**
+ * 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 Conflict stageAll(Transaction txn, List toMerge) {
+ for (SchemaToMerge item : toMerge) {
+ // 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 collisions = new ArrayList<>();
+ ColumnNameChecks.findCaseCollisions(
+ txn.table().schema().asStruct(), item.schema.asStruct(), "", collisions);
+ if (!collisions.isEmpty()) {
+ return new Conflict(
+ item, "conflicts with another file schema in the same window: " + describe(collisions));
+ }
+ // 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) {
+ return new Conflict(
+ item,
+ "conflicts with another file schema in the same window: " + AddFiles.errorMessage(e));
+ }
+ }
+ return null;
+ }
+
+ private static String describe(List changes) {
+ List 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
+ * against is thrown as {@link CommitFailedException} so the retry in {@link CommitSchemaUnion}
+ * reloads and rebuilds, leaving the replay checkState as a pure bug detector.
+ */
+ static Transaction transactionOn(Table table, Schema base, TableIdentifier tableId) {
+ Transaction txn = table.newTransaction();
+ if (!txn.table().schema().sameSchema(base)) {
+ throw new CommitFailedException(
+ "concurrent schema change on %s while staging the schema union", tableId);
+ }
+ return txn;
+ }
+
+ private static void stage(Transaction txn, SchemaToMerge item) {
+ UpdateSchema update = txn.updateSchema().unionByNameWith(item.schema);
+ if (item.delta != null) {
+ for (String path : item.delta.absentRequiredPaths()) {
+ update = update.makeColumnOptional(path);
+ }
+ }
+ update.commit();
+ }
+
+ /**
+ * New columns are optional at every level. The union adds top-level columns optional but keeps
+ * the file's optionality below them, so one file's luck would otherwise impose required fields on
+ * everyone. Pins do not shape new columns: they keep existing required columns from being relaxed
+ * (SchemaDelta) and gate files at registration.
+ */
+ private static void relaxNewRequiredFields(Transaction txn, Schema before) {
+ List toRelax = newRequiredPaths(before, txn.table().schema());
+ if (toRelax.isEmpty()) {
+ return;
+ }
+ UpdateSchema update = txn.updateSchema();
+ for (String path : toRelax) {
+ update = update.makeColumnOptional(path);
+ }
+ update.commit();
+ }
+
+ /**
+ * Paths of required fields that {@code after} has and {@code before} lacks, in schema order;
+ * includes fields under lists and maps (a required list element or map value counts). Map key
+ * subtrees are skipped: keys are required by definition and relaxing inside a struct key would
+ * change key identity.
+ */
+ static List newRequiredPaths(Schema before, Schema after) {
+ Set beforeIds = TypeUtil.indexById(before.asStruct()).keySet();
+ List paths = new ArrayList<>();
+ collectNewRequired(after.asStruct(), "", beforeIds, paths);
+ return paths;
+ }
+
+ private static void collectNewRequired(
+ Type.NestedType type, String prefix, Set beforeIds, List paths) {
+ for (Types.NestedField field : type.fields()) {
+ if (type.isMapType() && field.fieldId() == type.asMapType().keyId()) {
+ continue;
+ }
+ String path = prefix + field.name();
+ if (!beforeIds.contains(field.fieldId()) && field.isRequired()) {
+ paths.add(path);
+ }
+ if (field.type().isNestedType()) {
+ collectNewRequired(field.type().asNestedType(), path + ".", beforeIds, paths);
+ }
+ }
+ }
+
+ /** The table's name mapping property is absent, malformed or does not cover {@code schema}. */
+ static boolean needsNameMapping(Map tableProperties, Schema schema) {
+ @Nullable NameMapping existing =
+ NameMappingUtils.parseOrNull(tableProperties.get(TableProperties.DEFAULT_NAME_MAPPING));
+ return existing == null || !NameMappingUtils.covers(existing, schema.asStruct());
+ }
+}
diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesSchemaTransformProviderTest.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesSchemaTransformProviderTest.java
index 2e469ef6da71..3b58af201543 100644
--- a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesSchemaTransformProviderTest.java
+++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesSchemaTransformProviderTest.java
@@ -137,6 +137,20 @@ public void testInvalidHandlingValuesRejected() {
.getSchemaEvolution());
}
+ @Test
+ public void testDryRunParsed() {
+ SchemaEvolutionConfig config =
+ base()
+ .setSchemaEvolutionOptions(Arrays.asList("ALLOW_FIELD_ADDITION"))
+ .setDryRun(true)
+ .build()
+ .getSchemaEvolution();
+ assertNotNull(config);
+ assertTrue(config.getDryRun());
+ assertThrows(
+ IllegalArgumentException.class, () -> base().setDryRun(true).build().getSchemaEvolution());
+ }
+
@Test
public void testRouteToErrorsWithoutErrorHandlingRejected() {
IllegalArgumentException e =
diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
index 5e6cbdf4ea32..b9fe94f27a5c 100644
--- a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
+++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
@@ -26,7 +26,9 @@
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.hasEntry;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -34,8 +36,10 @@
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
@@ -71,6 +75,7 @@
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists;
import org.apache.hadoop.conf.Configuration;
+import org.apache.iceberg.BaseTable;
import org.apache.iceberg.DataFile;
import org.apache.iceberg.FileFormat;
import org.apache.iceberg.FileScanTask;
@@ -1047,6 +1052,315 @@ public void testEvolutionAddsColumnsBeforeRegisteringFiles() throws Exception {
"stats for the added column", nullCountsOf(table, "wide.parquet").containsKey(emailId));
}
+ @Test
+ public void testDryRunReportsWithoutCommittingOrRegistering() throws Exception {
+ catalog.createTable(tableId, icebergSchema);
+ String before = metadataLocation();
+ String covered = writeOneRecord("covered.parquet");
+ String wide = writeWider("wide.parquet");
+ String conflict = writeConflicting("conflict.parquet");
+
+ PCollectionRowTuple output =
+ pipeline.apply("Create Input", Create.of(covered, wide, conflict)).apply(addFiles(DRY_RUN));
+ PAssert.that(output.get("errors")).empty();
+ PAssert.that(output.get("snapshots")).empty();
+ PAssert.that(output.get(AddFiles.DRY_RUN_TAG))
+ .satisfies(
+ rows -> {
+ Row report = report(rows);
+ Collection schemas = schemas(report);
+ assertEquals(3, schemas.size());
+ boolean sawAddition = false;
+ boolean sawConflict = false;
+ for (Row schema : schemas) {
+ Collection changes = schema.getArray("changes");
+ if (changes.contains("add optional email string")) {
+ sawAddition = schema.getBoolean("allowed");
+ }
+ if (!schema.getBoolean("allowed")) {
+ sawConflict = schema.getString("reason").contains("conflicts");
+ }
+ }
+ assertTrue(sawAddition);
+ assertTrue(sawConflict);
+ assertFalse(report.getBoolean("allowed"));
+ assertEquals(Long.valueOf(2), report.getInt64("files_allowed"));
+ assertEquals(Long.valueOf(1), report.getInt64("files_incompatible"));
+ assertThat(report.getString("reason"), containsString("would fail"));
+ return null;
+ });
+ assertEquals(0, countTransforms(pipeline, "ConvertToDataFiles"));
+ PipelineResult result = pipeline.run();
+ result.waitUntilFinish();
+ Table table = catalog.loadTable(tableId);
+ assertEquals(0, Iterables.size(table.snapshots()));
+ assertEquals(2, counted(result, DryRunReport.class, DryRunReport.FILES_ALLOWED_COUNTER));
+ assertEquals(1, counted(result, DryRunReport.class, DryRunReport.FILES_INCOMPATIBLE_COUNTER));
+ assertEquals(0, counted(result, DryRunReport.class, DryRunReport.FILES_UNREADABLE_COUNTER));
+ assertEquals(0, counted(result, DryRunReport.class, DryRunReport.CONFIG_PROBLEMS_COUNTER));
+ assertEquals(before, metadataLocation());
+ }
+
+ private String metadataLocation() {
+ return ((BaseTable) catalog.loadTable(tableId)).operations().current().metadataFileLocation();
+ }
+
+ private static Row report(Iterable rows) {
+ return Iterables.getOnlyElement(rows);
+ }
+
+ private static Collection schemas(Row report) {
+ return checkStateNotNull(report.getArray("schemas"));
+ }
+
+ @Test
+ public void testDryRunAgainstMissingTableReportsCreation() throws Exception {
+ String wide = writeWider("wide.parquet");
+ PCollectionRowTuple output =
+ pipeline.apply("Create Input", Create.of(wide)).apply(addFiles(DRY_RUN));
+ PAssert.that(output.get(AddFiles.DRY_RUN_TAG))
+ .satisfies(
+ rows -> {
+ Row report = report(rows);
+ assertTrue(report.getBoolean("allowed"));
+ assertTrue(report.getBoolean("would_create_table"));
+ Row created = checkStateNotNull(report.getRow("created_table"));
+ assertThat(
+ created.getArray("columns").toString(),
+ containsString("create optional email string"));
+ assertThat(created.getString("schema"), containsString("\"name\":\"email\""));
+ for (Row schema : schemas(report)) {
+ assertTrue(
+ "the created table is described once", schema.getArray("changes").isEmpty());
+ }
+ return null;
+ });
+ pipeline.run().waitUntilFinish();
+ assertFalse(catalog.tableExists(tableId));
+ }
+
+ /** A real run under FAIL_PIPELINE throws before creating the table, and the report says so. */
+ @Test
+ public void testDryRunAgainstMissingTableDoesNotCreateWhenARealRunWouldFail() throws Exception {
+ String wide = writeWider("wide.parquet");
+ String conflict = writeConflicting("conflict.parquet");
+ PCollectionRowTuple output =
+ pipeline.apply("Create Input", Create.of(wide, conflict)).apply(addFiles(DRY_RUN));
+ PAssert.that(output.get(AddFiles.DRY_RUN_TAG))
+ .satisfies(
+ rows -> {
+ Row report = report(rows);
+ assertFalse(report.getBoolean("allowed"));
+ assertFalse(report.getBoolean("would_create_table"));
+ assertNotNull("the union still describes the table", report.getRow("created_table"));
+ assertThat(report.getString("reason"), containsString("would fail"));
+ return null;
+ });
+ pipeline.run().waitUntilFinish();
+ assertFalse(catalog.tableExists(tableId));
+ }
+
+ /** A table-level change without a schema change is reported as such. */
+ @Test
+ public void testDryRunReportsNameMappingRepair() throws Exception {
+ catalog.createTable(tableId, icebergSchema);
+ String before = metadataLocation();
+ String covered = writeOneRecord("covered.parquet");
+ PCollectionRowTuple output =
+ pipeline.apply("Create Input", Create.of(covered)).apply(addFiles(DRY_RUN));
+ PAssert.that(output.get(AddFiles.DRY_RUN_TAG))
+ .satisfies(
+ rows -> {
+ Row report = report(rows);
+ assertTrue(report.getBoolean("allowed"));
+ assertEquals(
+ Arrays.asList(DryRunReport.NAME_MAPPING_CHANGE),
+ new ArrayList<>(checkStateNotNull(report.getArray("table_changes"))));
+ return null;
+ });
+ pipeline.run().waitUntilFinish();
+ assertEquals(before, metadataLocation());
+ }
+
+ /** Creation settings are part of the plan: a partition field the union lacks is reported. */
+ @Test
+ public void testDryRunReportsCreationBlockedByPartitionFields() throws Exception {
+ String wide = writeWider("wide.parquet");
+ AddFiles partitionedByMissing =
+ new AddFiles(
+ catalogConfig,
+ tableId.toString(),
+ null,
+ Arrays.asList("missing"),
+ null,
+ null,
+ null,
+ null,
+ DRY_RUN);
+ PCollectionRowTuple output =
+ pipeline.apply("Create Input", Create.of(wide)).apply(partitionedByMissing);
+ PAssert.that(output.get(AddFiles.DRY_RUN_TAG))
+ .satisfies(
+ rows -> {
+ Row report = report(rows);
+ assertFalse(report.getBoolean("allowed"));
+ assertFalse(report.getBoolean("would_create_table"));
+ assertThat(report.getString("reason"), containsString("would fail to create"));
+ assertThat(report.getString("reason"), containsString("partition fields [missing]"));
+ assertEquals(1, checkStateNotNull(report.getArray("config_problems")).size());
+ return null;
+ });
+ PipelineResult result = pipeline.run();
+ result.waitUntilFinish();
+ assertEquals(1, counted(result, DryRunReport.class, DryRunReport.CONFIG_PROBLEMS_COUNTER));
+ assertFalse(catalog.tableExists(tableId));
+ }
+
+ /**
+ * The dry run reuses the real fold on scratch transactions: a schema compatible with the table
+ * but incompatible with another schema of the input is reported, as a real run would.
+ */
+ @Test
+ public void testDryRunSurfacesCrossSchemaConflicts() throws Exception {
+ catalog.createTable(tableId, icebergSchema);
+ Schema emailInt =
+ new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.required(2, "name", Types.StringType.get()),
+ Types.NestedField.required(3, "age", Types.IntegerType.get()),
+ Types.NestedField.optional(4, "email", Types.IntegerType.get()));
+ String a = writeWider("email_string.parquet");
+ Record intRecord = GenericRecord.create(emailInt);
+ intRecord.setField("id", 1);
+ intRecord.setField("name", "a");
+ intRecord.setField("age", 1);
+ intRecord.setField("email", 7);
+ String b = writeWithSchema("email_int.parquet", emailInt, intRecord);
+
+ PCollectionRowTuple output =
+ pipeline.apply("Create Input", Create.of(a, b)).apply(addFiles(DRY_RUN));
+ PAssert.that(output.get(AddFiles.DRY_RUN_TAG))
+ .satisfies(
+ rows -> {
+ Collection schemas = schemas(report(rows));
+ assertEquals(2, schemas.size());
+ int allowed = 0;
+ for (Row schema : schemas) {
+ if (schema.getBoolean("allowed")) {
+ allowed++;
+ }
+ }
+ assertEquals("one of the two schemas loses the fold", 1, allowed);
+ return null;
+ });
+ pipeline.run().waitUntilFinish();
+ assertNull(catalog.loadTable(tableId).schema().findField("email"));
+ }
+
+ /** With evolution on, the pre-pass is the only creator; registration never falls back to one. */
+ @Test
+ public void testMissingTableIsNotCreatedAtRegistrationWithEvolution() throws Exception {
+ File avro = temp.newFile("data.avro");
+ File garbage = temp.newFile("garbage.parquet");
+ java.nio.file.Files.write(garbage.toPath(), "not parquet".getBytes(StandardCharsets.UTF_8));
+ PCollectionRowTuple output =
+ pipeline
+ .apply("Create Input", Create.of(avro.getAbsolutePath(), garbage.getAbsolutePath()))
+ .apply(addFiles(ADDITIONS));
+ PAssert.that(output.get("snapshots")).empty();
+ PAssert.that(output.get("errors"))
+ .satisfies(
+ rows -> {
+ int count = 0;
+ for (Row row : rows) {
+ count++;
+ assertThat(
+ row.getString("error"),
+ containsString(AddFiles.ConvertToDataFile.MISSING_TABLE_ERROR));
+ }
+ assertEquals(2, count);
+ return null;
+ });
+ pipeline.run().waitUntilFinish();
+ assertFalse(catalog.tableExists(tableId));
+ }
+
+ /** Nothing can create the table, so nothing is allowed, whatever ACCEPT would register. */
+ @Test
+ public void testDryRunAgainstMissingTableWithNoUsableSchema() throws Exception {
+ File avro = temp.newFile("data.avro");
+ SchemaEvolutionConfig config =
+ SchemaEvolutionConfig.builder()
+ .setOptions(EnumSet.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION))
+ .setUnverifiableFileHandling(UnverifiableFileHandling.ACCEPT)
+ .setDryRun(true)
+ .build();
+ PCollectionRowTuple output =
+ pipeline.apply("Create Input", Create.of(avro.getAbsolutePath())).apply(addFiles(config));
+ PAssert.that(output.get(AddFiles.DRY_RUN_TAG))
+ .satisfies(
+ rows -> {
+ Row report = report(rows);
+ assertFalse(report.getBoolean("allowed"));
+ assertFalse(report.getBoolean("would_create_table"));
+ assertFalse(report.getBoolean("unchecked_registered"));
+ assertEquals(Long.valueOf(1), report.getInt64("files_unchecked"));
+ assertThat(report.getString("reason"), containsString(DryRunReport.NO_TABLE_REASON));
+ return null;
+ });
+ pipeline.run().waitUntilFinish();
+ assertFalse(catalog.tableExists(tableId));
+ }
+
+ /** Files that contribute no schema are counted instead of vanishing. */
+ @Test
+ public void testDryRunReportsUnreadableAndNonParquetFiles() throws Exception {
+ dryRunWithUnreadableAndAvro(UnverifiableFileHandling.REJECT, false);
+ }
+
+ @Test
+ public void testDryRunReportsNonParquetFilesAsRegisteredWhenAccepted() throws Exception {
+ dryRunWithUnreadableAndAvro(UnverifiableFileHandling.ACCEPT, true);
+ }
+
+ private void dryRunWithUnreadableAndAvro(
+ UnverifiableFileHandling handling, boolean uncheckedRegistered) throws Exception {
+ catalog.createTable(tableId, icebergSchema);
+ String good = writeOneRecord("good.parquet");
+ File garbage = temp.newFile("garbage.parquet");
+ java.nio.file.Files.write(garbage.toPath(), "not parquet".getBytes(StandardCharsets.UTF_8));
+ File avro = temp.newFile("data.avro");
+
+ SchemaEvolutionConfig config =
+ SchemaEvolutionConfig.builder()
+ .setOptions(EnumSet.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION))
+ .setUnverifiableFileHandling(handling)
+ .setDryRun(true)
+ .build();
+ PCollectionRowTuple output =
+ pipeline
+ .apply(
+ "Create Input", Create.of(good, garbage.getAbsolutePath(), avro.getAbsolutePath()))
+ .apply(addFiles(config));
+ PAssert.that(output.get(AddFiles.DRY_RUN_TAG))
+ .satisfies(
+ rows -> {
+ Row report = report(rows);
+ assertTrue(report.getBoolean("allowed"));
+ assertEquals(Long.valueOf(1), report.getInt64("files_allowed"));
+ assertEquals(Long.valueOf(1), report.getInt64("files_unreadable"));
+ assertEquals(Long.valueOf(1), report.getInt64("files_unchecked"));
+ assertEquals(uncheckedRegistered, report.getBoolean("unchecked_registered"));
+ return null;
+ });
+ PipelineResult result = pipeline.run();
+ result.waitUntilFinish();
+ assertEquals(1, counted(result, DryRunReport.class, DryRunReport.FILES_ALLOWED_COUNTER));
+ assertEquals(0, counted(result, DryRunReport.class, DryRunReport.FILES_INCOMPATIBLE_COUNTER));
+ assertEquals(1, counted(result, DryRunReport.class, DryRunReport.FILES_UNREADABLE_COUNTER));
+ assertEquals(1, counted(result, DryRunReport.class, DryRunReport.FILES_UNCHECKED_COUNTER));
+ }
+
@Test
public void testEvolutionDisabledAddsNoPrePassTransforms() throws Exception {
catalog.createTable(tableId, icebergSchema);
@@ -1178,7 +1492,8 @@ public void testUpstreamWindowedBatchInputEvolvesAndRegisters() throws Exception
/**
* The schema commit retries a CommitFailedException (another writer got in first). The committer
- * is serialized with the DoFn, so only the table shows the retry happened.
+ * is serialized with the DoFn, so the retry shows in the table and in the time the backoff
+ * reports to the runner as throttled.
*/
@Test
public void testTransientSchemaCommitFailureIsRetried() throws Exception {
@@ -1199,9 +1514,25 @@ public void testTransientSchemaCommitFailureIsRetried() throws Exception {
.apply(addFiles(ADDITIONS).withSchemaCommitter(failsOnce));
PAssert.that(output.get("errors")).empty();
- pipeline.run().waitUntilFinish();
+ PipelineResult result = pipeline.run();
+ result.waitUntilFinish();
assertEmailAddedAndFilesRegistered(1);
+ long throttledMillis = 0;
+ for (MetricResult metric :
+ result
+ .metrics()
+ .queryMetrics(
+ MetricsFilter.builder()
+ .addNameFilter(
+ MetricNameFilter.named(
+ org.apache.beam.sdk.metrics.Metrics.THROTTLE_TIME_NAMESPACE,
+ org.apache.beam.sdk.metrics.Metrics.THROTTLE_TIME_COUNTER_NAME))
+ .build())
+ .getCounters()) {
+ throttledMillis += metric.getAttempted();
+ }
+ assertTrue("one backoff wait was reported: " + throttledMillis, throttledMillis > 0);
}
// ---- ConvertToDataFile coverage check and pinned columns
@@ -1209,6 +1540,12 @@ public void testTransientSchemaCommitFailureIsRetried() throws Exception {
private static final SchemaEvolutionConfig ADDITIONS =
SchemaEvolutionConfig.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION);
+ private static final SchemaEvolutionConfig DRY_RUN =
+ SchemaEvolutionConfig.builder()
+ .setOptions(EnumSet.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION))
+ .setDryRun(true)
+ .build();
+
private PCollectionTuple convert(SchemaEvolutionConfig config, String... files) {
PCollectionTuple out =
pipeline
@@ -1590,13 +1927,17 @@ private static SchemaEvolutionConfig accepting(SchemaEvolutionConfig base) {
}
private static long counted(PipelineResult result, String counter) {
+ return counted(result, AddFiles.class, counter);
+ }
+
+ private static long counted(PipelineResult result, Class> namespace, String counter) {
long total = 0;
for (MetricResult metric :
result
.metrics()
.queryMetrics(
MetricsFilter.builder()
- .addNameFilter(MetricNameFilter.named(AddFiles.class, counter))
+ .addNameFilter(MetricNameFilter.named(namespace, counter))
.build())
.getCounters()) {
total += metric.getAttempted();
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
index e3d6889b1616..07a0adeb6cfb 100644
--- 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
@@ -89,9 +89,10 @@ public void testCommitsTheWindowsSchemasAndEmitsTheSchemaId() {
new CommitSchemaOnce(
catalogConfig,
"default." + testName.getMethodName(),
- SchemaEvolutionConfig.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION),
- IncompatibleSchemaHandling.FAIL_PIPELINE,
- new CommitSchemaUnion.TableCreation(null, null, null))));
+ new CommitSchemaUnion.Settings(
+ SchemaEvolutionConfig.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION),
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ new CommitSchemaUnion.NewTableSettings(null, null, null)))));
PAssert.that(schemaIds).containsInAnyOrder(1L);
PipelineResult result = pipeline.run();
result.waitUntilFinish();
@@ -130,9 +131,10 @@ public void testNoOpWindowDoesNotCountACommit() {
new CommitSchemaOnce(
catalogConfig,
"default." + testName.getMethodName(),
- SchemaEvolutionConfig.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION),
- IncompatibleSchemaHandling.FAIL_PIPELINE,
- new CommitSchemaUnion.TableCreation(null, null, null))));
+ new CommitSchemaUnion.Settings(
+ SchemaEvolutionConfig.of(SchemaEvolutionOption.ALLOW_FIELD_ADDITION),
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ new CommitSchemaUnion.NewTableSettings(null, null, null)))));
PipelineResult result = pipeline.run();
result.waitUntilFinish();
assertEquals(0, commitsCounted(result));
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 7df6af49920c..0c5f379a1105 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
@@ -19,9 +19,12 @@
import static org.apache.iceberg.types.Types.NestedField.optional;
import static org.apache.iceberg.types.Types.NestedField.required;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -35,6 +38,7 @@
import org.apache.beam.sdk.io.iceberg.CommitSchemaUnion.Committer;
import org.apache.beam.sdk.io.iceberg.CommitSchemaUnion.IncompatibleSchemaException;
import org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.IncompatibleSchemaHandling;
+import org.apache.beam.sdk.util.FastNanoClockAndSleeper;
import org.apache.hadoop.conf.Configuration;
import org.apache.iceberg.BaseTable;
import org.apache.iceberg.Schema;
@@ -65,6 +69,9 @@ public class CommitSchemaUnionTest {
@Rule public TestName testName = new TestName();
+ /** Advances a fake clock instead of sleeping out the commit backoff. */
+ @Rule public FastNanoClockAndSleeper fastClock = new FastNanoClockAndSleeper();
+
private static final Schema TABLE =
new Schema(
required(1, "id", Types.LongType.get()),
@@ -77,8 +84,15 @@ 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 static final CommitSchemaUnion.NewTableSettings NO_CREATION =
+ new CommitSchemaUnion.NewTableSettings(null, null, null);
+
+ private static CommitSchemaUnion.Settings settings(
+ SchemaEvolutionConfig config,
+ IncompatibleSchemaHandling handling,
+ CommitSchemaUnion.NewTableSettings newTable) {
+ return new CommitSchemaUnion.Settings(config, handling, newTable);
+ }
private HadoopCatalog catalog;
private TableIdentifier tableId;
@@ -111,9 +125,7 @@ private long commit(
catalog,
tableId,
Arrays.asList(schemas),
- config,
- handling,
- NO_CREATION,
+ settings(config, handling, NO_CREATION),
CommitSchemaUnion.DEFAULT_COMMITTER);
}
@@ -260,99 +272,6 @@ public void testFieldsInsideAddedContainersAreOptional() {
assertTrue(schema.findField("attrs.value.v").isOptional());
}
- // ---- newRequiredPaths (direct)
-
- @Test
- public void testNewRequiredPathsAtEveryLevelExceptMapKeys() {
- Schema before = new Schema(required(1, "id", Types.LongType.get()));
- Schema after =
- new Schema(
- required(1, "id", Types.LongType.get()),
- optional(
- 2,
- "s",
- Types.StructType.of(
- required(3, "a", Types.IntegerType.get()),
- optional(4, "b", Types.IntegerType.get()))),
- optional(
- 5,
- "items",
- Types.ListType.ofRequired(
- 6, Types.StructType.of(required(7, "qty", Types.IntegerType.get())))),
- optional(
- 8,
- "attrs",
- Types.MapType.ofRequired(
- 9,
- 10,
- Types.StructType.of(required(11, "k", Types.StringType.get())),
- Types.StructType.of(required(12, "v", Types.IntegerType.get())))));
- assertEquals(
- Arrays.asList("s.a", "items.element", "items.element.qty", "attrs.value", "attrs.value.v"),
- CommitSchemaUnion.newRequiredPaths(before, after));
- }
-
- /** Names containing element/key/value are not containers; regression for a substring check. */
- @Test
- public void testNewRequiredPathsContainerLikeNamesAreNotContainers() {
- Schema before = new Schema(required(1, "id", Types.LongType.get()));
- Schema after =
- new Schema(
- required(1, "id", Types.LongType.get()),
- optional(
- 2,
- "stats",
- Types.StructType.of(
- required(3, "keyword", Types.StringType.get()),
- required(4, "value_sum", Types.LongType.get()),
- required(5, "element", Types.StringType.get()))));
- assertEquals(
- Arrays.asList("stats.keyword", "stats.value_sum", "stats.element"),
- CommitSchemaUnion.newRequiredPaths(before, after));
- }
-
- @Test
- public void testNewRequiredPathsInNestedContainers() {
- Schema before = new Schema(required(1, "id", Types.LongType.get()));
- Schema after =
- new Schema(
- required(1, "id", Types.LongType.get()),
- optional(
- 2,
- "ll",
- Types.ListType.ofRequired(
- 3, Types.ListType.ofRequired(4, Types.IntegerType.get()))),
- optional(
- 5,
- "lm",
- Types.ListType.ofRequired(
- 6,
- Types.MapType.ofRequired(
- 7, 8, Types.StringType.get(), Types.IntegerType.get()))));
- assertEquals(
- Arrays.asList("ll.element", "ll.element.element", "lm.element", "lm.element.value"),
- CommitSchemaUnion.newRequiredPaths(before, after));
- }
-
- /** Growing an existing struct: only the field with a new id is a candidate. */
- @Test
- public void testNewRequiredPathsIgnoreExistingFields() {
- Schema before =
- new Schema(
- required(1, "id", Types.LongType.get()),
- optional(2, "s", Types.StructType.of(required(3, "old", Types.IntegerType.get()))));
- Schema after =
- new Schema(
- required(1, "id", Types.LongType.get()),
- optional(
- 2,
- "s",
- Types.StructType.of(
- required(3, "old", Types.IntegerType.get()),
- required(4, "fresh", Types.IntegerType.get()))));
- assertEquals(Arrays.asList("s.fresh"), CommitSchemaUnion.newRequiredPaths(before, after));
- }
-
/** A declared-optional column every file proved null-free does not relax the table. */
@Test
public void testNullFreeColumnIsNotRelaxed() {
@@ -550,9 +469,7 @@ public void testFinalSchemaIsOrderIndependent() {
catalog,
other,
Arrays.asList(files(b, 2), files(a, 1)),
- ALL,
- IncompatibleSchemaHandling.FAIL_PIPELINE,
- NO_CREATION,
+ settings(ALL, IncompatibleSchemaHandling.FAIL_PIPELINE, NO_CREATION),
CommitSchemaUnion.DEFAULT_COMMITTER);
assertTrue(first.sameSchema(catalog.loadTable(other).schema()));
}
@@ -742,18 +659,21 @@ public void testCommitFailedOnceIsRetriedAgainstFreshState() {
catalog,
tableId,
Arrays.asList(files(file, 1)),
- ALL,
- IncompatibleSchemaHandling.FAIL_PIPELINE,
- NO_CREATION,
- flakyThenExternalChange);
+ settings(ALL, IncompatibleSchemaHandling.FAIL_PIPELINE, NO_CREATION),
+ flakyThenExternalChange,
+ fastClock);
Table table = load();
assertEquals(2, attempts.get());
assertNotNull(table.schema().findField("external"));
assertNotNull(table.schema().findField("email"));
}
- @Test
- public void testPersistentCommitFailurePropagates() {
+ private long millisSleptSince(long startNanos) {
+ return (fastClock.nanoTime() - startNanos) / 1_000_000;
+ }
+
+ /** A commit that always loses; returns how many times it was attempted. */
+ private int attemptsUntilGivingUp() {
AtomicInteger attempts = new AtomicInteger();
Committer alwaysFails =
txn -> {
@@ -772,11 +692,54 @@ public void testPersistentCommitFailurePropagates() {
catalog,
tableId,
Arrays.asList(files(file, 1)),
- ALL,
- IncompatibleSchemaHandling.FAIL_PIPELINE,
- NO_CREATION,
- alwaysFails));
- assertEquals(CommitSchemaUnion.MAX_ATTEMPTS, attempts.get());
+ settings(ALL, IncompatibleSchemaHandling.FAIL_PIPELINE, NO_CREATION),
+ alwaysFails,
+ fastClock));
+ return attempts.get();
+ }
+
+ /** The budget is time, not a count: the waits add up to the timeout, whatever the jitter. */
+ @Test
+ public void testPersistentCommitFailureGivesUpWhenTheRetryTimeIsSpent() {
+ long start = fastClock.nanoTime();
+ int attempts = attemptsUntilGivingUp();
+ assertEquals(
+ CommitSchemaUnion.DEFAULT_RETRY_TOTAL_TIMEOUT.getMillis(), millisSleptSince(start));
+ assertTrue("retried: " + attempts, attempts > 1);
+ assertNull("nothing was committed", load().schema().findField("email"));
+ }
+
+ @Test
+ public void testTableRetryPropertiesSetTheBudget() {
+ load()
+ .updateProperties()
+ .set(TableProperties.COMMIT_MIN_RETRY_WAIT_MS, "10")
+ .set(TableProperties.COMMIT_MAX_RETRY_WAIT_MS, "40")
+ .set(TableProperties.COMMIT_TOTAL_RETRY_TIME_MS, "200")
+ .commit();
+ long start = fastClock.nanoTime();
+ attemptsUntilGivingUp();
+ assertEquals(200, millisSleptSince(start));
+ }
+
+ /** A bad table property must not replace the commit failure with a configuration error. */
+ @Test
+ public void testUnusableTableRetryPropertiesAreClamped() {
+ load()
+ .updateProperties()
+ .set(TableProperties.COMMIT_MIN_RETRY_WAIT_MS, "0")
+ .set(TableProperties.COMMIT_TOTAL_RETRY_TIME_MS, "-5")
+ .set(TableProperties.COMMIT_NUM_RETRIES, "-1")
+ .commit();
+ long start = fastClock.nanoTime();
+ assertEquals("no retries", 1, attemptsUntilGivingUp());
+ assertEquals(0, millisSleptSince(start));
+ }
+
+ @Test
+ public void testTableRetryCountCapsTheAttempts() {
+ load().updateProperties().set(TableProperties.COMMIT_NUM_RETRIES, "2").commit();
+ assertEquals(3, attemptsUntilGivingUp());
}
// ---- create path
@@ -789,15 +752,13 @@ private long commitTo(
TableIdentifier id,
SchemaEvolutionConfig config,
IncompatibleSchemaHandling handling,
- CommitSchemaUnion.TableCreation creation,
+ CommitSchemaUnion.NewTableSettings creation,
CollectDistinctSchemas.SchemaGroup... schemas) {
return CommitSchemaUnion.commit(
catalog,
id,
Arrays.asList(schemas),
- config,
- handling,
- creation,
+ settings(config, handling, creation),
CommitSchemaUnion.DEFAULT_COMMITTER);
}
@@ -812,8 +773,8 @@ public void testMissingTableIsCreatedFromTheUnion() {
Schema other =
new Schema(
required(1, "id", Types.LongType.get()), optional(2, "extra", Types.LongType.get()));
- CommitSchemaUnion.TableCreation creation =
- new CommitSchemaUnion.TableCreation(
+ CommitSchemaUnion.NewTableSettings creation =
+ new CommitSchemaUnion.NewTableSettings(
Arrays.asList("region"), null, java.util.Collections.singletonMap("k", "v"));
long schemaId =
commitTo(
@@ -848,8 +809,8 @@ public void testPartitionFieldFromNonSeedSchemaResolves() {
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);
+ CommitSchemaUnion.NewTableSettings creation =
+ new CommitSchemaUnion.NewTableSettings(Arrays.asList("extra"), null, null);
commitTo(
id,
ALL,
@@ -965,82 +926,6 @@ public void testUnmatchedPinWarnsAndCreatesUnderRouteToErrors() {
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() {
@@ -1068,9 +953,7 @@ public void testMissingTableWithoutSchemasIsNotCreated() {
catalog,
id,
new ArrayList<>(),
- ALL,
- IncompatibleSchemaHandling.FAIL_PIPELINE,
- NO_CREATION,
+ settings(ALL, IncompatibleSchemaHandling.FAIL_PIPELINE, NO_CREATION),
CommitSchemaUnion.DEFAULT_COMMITTER);
assertEquals(CommitSchemaUnion.NO_TABLE, result);
assertFalse(catalog.tableExists(id));
@@ -1098,6 +981,58 @@ public void testConflictOnCreateFailsWithoutCreating() {
assertFalse(catalog.tableExists(id));
}
+ /** The fallback creation at registration throws the same error, so no handling can route it. */
+ @Test
+ public void testPartitionFieldAbsentFromUnionFailsCreationUnderEitherHandling() {
+ TableIdentifier id = missing();
+ Schema seed =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "region", Types.StringType.get()));
+ CommitSchemaUnion.NewTableSettings creation =
+ new CommitSchemaUnion.NewTableSettings(Arrays.asList("missing"), null, null);
+ for (IncompatibleSchemaHandling handling : IncompatibleSchemaHandling.values()) {
+ IllegalStateException e =
+ assertThrows(
+ IllegalStateException.class,
+ () -> commitTo(id, ALL, handling, creation, files(seed, 2)));
+ assertEquals(
+ "not an IncompatibleSchemaException, which the handling could downgrade",
+ IllegalStateException.class,
+ e.getClass());
+ assertThat(
+ e.getMessage(), containsString("cannot be created with partition fields [missing]"));
+ }
+ assertFalse(catalog.tableExists(id));
+ }
+
+ @Test
+ public void testPartitionFieldOnlyInASkippedSchemaFailsCreation() {
+ TableIdentifier id = missing();
+ Schema seed =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "code", Types.StringType.get()));
+ Schema loser =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ optional(2, "code", Types.LongType.get()),
+ optional(3, "region", Types.StringType.get()));
+ CommitSchemaUnion.NewTableSettings creation =
+ new CommitSchemaUnion.NewTableSettings(Arrays.asList("region"), null, null);
+ IllegalStateException e =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ commitTo(
+ id,
+ ALL,
+ IncompatibleSchemaHandling.ROUTE_TO_ERRORS,
+ creation,
+ files(seed, 3),
+ files(loser, 1)));
+ assertThat(e.getMessage(), containsString("region"));
+ assertFalse(catalog.tableExists(id));
+ }
+
@Test
public void testConflictOnCreateRoutesLoserAndCreates() {
TableIdentifier id = missing();
@@ -1142,10 +1077,9 @@ public void testCreateRaceFallsBackToEvolvingTheExistingTable() {
catalog,
id,
Arrays.asList(files(file, 1)),
- ALL,
- IncompatibleSchemaHandling.FAIL_PIPELINE,
- NO_CREATION,
- raced);
+ settings(ALL, IncompatibleSchemaHandling.FAIL_PIPELINE, NO_CREATION),
+ raced,
+ fastClock);
Table table = catalog.loadTable(id);
assertEquals(2, attempts.get());
assertNotNull(table.schema().findField("email"));
@@ -1156,16 +1090,13 @@ public void testCreateRaceFallsBackToEvolvingTheExistingTable() {
@Test
public void testEmptyInputCommitsNothing() {
- seedNameMapping();
String before = metadataLocation(load());
List none = new ArrayList<>();
CommitSchemaUnion.commit(
catalog,
tableId,
none,
- ALL,
- IncompatibleSchemaHandling.FAIL_PIPELINE,
- NO_CREATION,
+ settings(ALL, IncompatibleSchemaHandling.FAIL_PIPELINE, NO_CREATION),
CommitSchemaUnion.DEFAULT_COMMITTER);
assertEquals(before, metadataLocation(load()));
}
diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java
index 5cac095201db..4f17bc738400 100644
--- a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java
+++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java
@@ -253,7 +253,7 @@ private static long counter(PipelineResult result, String name) {
private PCollection run(String... paths) {
return pipeline
.apply(Create.of(Arrays.asList(paths)))
- .apply(ParDo.of(new ReadFooterSchema()))
+ .apply(ParDo.of(new ReadFooterSchema(SchemaEvolutionConfig.disabled())))
.setCoder(CollectDistinctSchemas.groupCoder());
}
diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfigTest.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfigTest.java
index c6d565cda957..c76c06d9b7c7 100644
--- a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfigTest.java
+++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaEvolutionConfigTest.java
@@ -116,4 +116,14 @@ public void testIncompatibleSchemaHandlingDefaultsByMode() {
IncompatibleSchemaHandling.ROUTE_TO_ERRORS,
forced.incompatibleSchemaHandlingFor(PCollection.IsBounded.BOUNDED));
}
+
+ /** A dry run of nothing is a config error, not a silent real run. */
+ @Test
+ public void testDryRunAloneIsRejectedAtBuild() {
+ IllegalArgumentException e =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> SchemaEvolutionConfig.builder().setDryRun(true).build());
+ assertTrue(e.getMessage(), e.getMessage().contains("dry run"));
+ }
}
diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaPlanTest.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaPlanTest.java
new file mode 100644
index 000000000000..12d34d773b84
--- /dev/null
+++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SchemaPlanTest.java
@@ -0,0 +1,325 @@
+/*
+ * 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.util.Preconditions.checkStateNotNull;
+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.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import org.apache.beam.sdk.io.iceberg.SchemaEvolutionConfig.IncompatibleSchemaHandling;
+import org.apache.hadoop.conf.Configuration;
+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.hadoop.HadoopCatalog;
+import org.apache.iceberg.types.TypeUtil;
+import org.apache.iceberg.types.Types;
+import org.junit.Before;
+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;
+
+/** Planning without committing; what a plan does once committed is CommitSchemaUnionTest's. */
+@RunWith(JUnit4.class)
+public class SchemaPlanTest {
+ @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();
+
+ private static final Schema TABLE =
+ 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()));
+
+ private static final SchemaEvolutionConfig ALL =
+ SchemaEvolutionConfig.of(SchemaEvolutionOption.values());
+
+ private static final CommitSchemaUnion.Settings SETTINGS =
+ new CommitSchemaUnion.Settings(
+ ALL,
+ IncompatibleSchemaHandling.FAIL_PIPELINE,
+ new CommitSchemaUnion.NewTableSettings(null, null, null));
+
+ private HadoopCatalog catalog;
+ private TableIdentifier tableId;
+
+ @Before
+ public void setUp() {
+ catalog = new HadoopCatalog(new Configuration(), warehouse.location);
+ tableId = TableIdentifier.of("default", testName.getMethodName());
+ warehouse.createTable(tableId, TABLE);
+ }
+
+ private static String json(Schema schema) {
+ return SchemaParser.toJson(FileSchemas.canonical(schema));
+ }
+
+ private static CollectDistinctSchemas.SchemaGroup files(Schema schema, long count) {
+ return CollectDistinctSchemas.SchemaGroup.of(json(schema), count, Collections.emptyList());
+ }
+
+ private Table load() {
+ 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());
+ }
+
+ // ---- plans
+
+ /** The mapping repair is a plan decision, made before anything is committed. */
+ @Test
+ public void testPlanReportsTheNameMappingRepair() {
+ Table table = load();
+ assertFalse(table.properties().containsKey(TableProperties.DEFAULT_NAME_MAPPING));
+ List covered = Arrays.asList(files(TABLE, 1));
+ SchemaPlan.Evolution plan =
+ (SchemaPlan.Evolution) SchemaPlan.compute(catalog, tableId, table, covered, SETTINGS);
+ assertNull(plan.newSchema);
+ assertTrue(plan.repairsNameMapping);
+
+ table
+ .updateProperties()
+ .set(
+ TableProperties.DEFAULT_NAME_MAPPING, NameMappingUtils.regenerate(table.schema(), null))
+ .commit();
+ plan = (SchemaPlan.Evolution) SchemaPlan.compute(catalog, tableId, load(), covered, SETTINGS);
+ assertFalse(plan.repairsNameMapping);
+ }
+
+ @Test
+ public void testPlanForCreationListsAcceptedSchemasAndCreationSettings() {
+ TableIdentifier missing = TableIdentifier.of("default", testName.getMethodName() + "_new");
+ Schema seed =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "region", Types.StringType.get()));
+ Schema other =
+ new Schema(
+ required(1, "id", Types.LongType.get()), optional(2, "extra", Types.LongType.get()));
+ CommitSchemaUnion.NewTableSettings creation =
+ new CommitSchemaUnion.NewTableSettings(Arrays.asList("region"), Arrays.asList("id"), null);
+ SchemaPlan.Creation plan =
+ (SchemaPlan.Creation)
+ SchemaPlan.compute(
+ catalog,
+ missing,
+ null,
+ Arrays.asList(files(seed, 5), files(other, 1)),
+ new CommitSchemaUnion.Settings(
+ ALL, IncompatibleSchemaHandling.FAIL_PIPELINE, creation));
+ assertTrue(plan.canCreate());
+ assertEquals(2, plan.schemasToMerge.size());
+ assertEquals(5, checkStateNotNull(plan.toMerge(json(seed))).files);
+ assertEquals(1, checkStateNotNull(plan.toMerge(json(other))).files);
+ assertEquals("region", checkStateNotNull(plan.spec).fields().get(0).name());
+ assertEquals(1, checkStateNotNull(plan.sortOrder).fields().size());
+ assertFalse(catalog.tableExists(missing));
+ }
+
+ // ---- newRequiredPaths (direct)
+
+ @Test
+ public void testNewRequiredPathsAtEveryLevelExceptMapKeys() {
+ Schema before = new Schema(required(1, "id", Types.LongType.get()));
+ Schema after =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ optional(
+ 2,
+ "s",
+ Types.StructType.of(
+ required(3, "a", Types.IntegerType.get()),
+ optional(4, "b", Types.IntegerType.get()))),
+ optional(
+ 5,
+ "items",
+ Types.ListType.ofRequired(
+ 6, Types.StructType.of(required(7, "qty", Types.IntegerType.get())))),
+ optional(
+ 8,
+ "attrs",
+ Types.MapType.ofRequired(
+ 9,
+ 10,
+ Types.StructType.of(required(11, "k", Types.StringType.get())),
+ Types.StructType.of(required(12, "v", Types.IntegerType.get())))));
+ assertEquals(
+ Arrays.asList("s.a", "items.element", "items.element.qty", "attrs.value", "attrs.value.v"),
+ SchemaPlan.newRequiredPaths(before, after));
+ }
+
+ /** Names containing element/key/value are not containers; regression for a substring check. */
+ @Test
+ public void testNewRequiredPathsContainerLikeNamesAreNotContainers() {
+ Schema before = new Schema(required(1, "id", Types.LongType.get()));
+ Schema after =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ optional(
+ 2,
+ "stats",
+ Types.StructType.of(
+ required(3, "keyword", Types.StringType.get()),
+ required(4, "value_sum", Types.LongType.get()),
+ required(5, "element", Types.StringType.get()))));
+ assertEquals(
+ Arrays.asList("stats.keyword", "stats.value_sum", "stats.element"),
+ SchemaPlan.newRequiredPaths(before, after));
+ }
+
+ @Test
+ public void testNewRequiredPathsInNestedContainers() {
+ Schema before = new Schema(required(1, "id", Types.LongType.get()));
+ Schema after =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ optional(
+ 2,
+ "ll",
+ Types.ListType.ofRequired(
+ 3, Types.ListType.ofRequired(4, Types.IntegerType.get()))),
+ optional(
+ 5,
+ "lm",
+ Types.ListType.ofRequired(
+ 6,
+ Types.MapType.ofRequired(
+ 7, 8, Types.StringType.get(), Types.IntegerType.get()))));
+ assertEquals(
+ Arrays.asList("ll.element", "ll.element.element", "lm.element", "lm.element.value"),
+ SchemaPlan.newRequiredPaths(before, after));
+ }
+
+ /** Growing an existing struct: only the field with a new id is a candidate. */
+ @Test
+ public void testNewRequiredPathsIgnoreExistingFields() {
+ Schema before =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ optional(2, "s", Types.StructType.of(required(3, "old", Types.IntegerType.get()))));
+ Schema after =
+ new Schema(
+ required(1, "id", Types.LongType.get()),
+ optional(
+ 2,
+ "s",
+ Types.StructType.of(
+ required(3, "old", Types.IntegerType.get()),
+ required(4, "fresh", Types.IntegerType.get()))));
+ assertEquals(Arrays.asList("s.fresh"), SchemaPlan.newRequiredPaths(before, after));
+ }
+
+ // ---- 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 = SchemaPlan.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()))))),
+ SchemaPlan.createdSchema(schema, ALL));
+ }
+}
diff --git a/sdks/python/apache_beam/yaml/standard_io.yaml b/sdks/python/apache_beam/yaml/standard_io.yaml
index c41dd6f2e07f..2d2c2af6da13 100644
--- a/sdks/python/apache_beam/yaml/standard_io.yaml
+++ b/sdks/python/apache_beam/yaml/standard_io.yaml
@@ -661,6 +661,7 @@
required_columns: 'required_columns'
incompatible_schema_handling: 'incompatible_schema_handling'
unverifiable_file_handling: 'unverifiable_file_handling'
+ dry_run: 'dry_run'
error_handling: 'error_handling'
underlying_provider:
type: beamJar
diff --git a/sdks/python/apache_beam/yaml/tests/iceberg_add_files_dry_run.yaml b/sdks/python/apache_beam/yaml/tests/iceberg_add_files_dry_run.yaml
new file mode 100644
index 000000000000..75b7b1a16ddc
--- /dev/null
+++ b/sdks/python/apache_beam/yaml/tests/iceberg_add_files_dry_run.yaml
@@ -0,0 +1,185 @@
+#
+# 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.
+#
+
+fixtures:
+ - name: TEMP_DIR
+ type: "tempfile.TemporaryDirectory"
+
+
+pipelines:
+ # Pipeline 1: files with two columns
+ - pipeline:
+ type: chain
+ transforms:
+ - type: Create
+ config:
+ elements:
+ - {label: "11a", rank: 0}
+ - {label: "37a", rank: 1}
+ - type: WriteToParquet
+ config:
+ path: "{TEMP_DIR}/data/narrow"
+ file_name_suffix: ".parquet"
+ num_shards: 1
+
+ # Pipeline 2: files with an extra column
+ - pipeline:
+ type: chain
+ transforms:
+ - type: Create
+ config:
+ elements:
+ - {label: "389a", rank: 2, bool: false}
+ - {label: "3821b", rank: 3, bool: true}
+ - type: WriteToParquet
+ config:
+ path: "{TEMP_DIR}/data/wide"
+ file_name_suffix: ".parquet"
+ num_shards: 1
+
+ # Pipeline 3: a dry run against a table that does not exist yet; the report is written as JSON
+ # from the dry_run_report output, the way a user keeps it
+ - pipeline:
+ transforms:
+ - type: ReadMatchFiles
+ name: Match
+ config:
+ file_pattern: "{TEMP_DIR}/data/*.parquet"
+ - type: MapToFields
+ name: Paths
+ input: Match
+ config:
+ language: python
+ fields:
+ path:
+ callable: "lambda row: row.path"
+ output_type: string
+ - type: IcebergAddFiles
+ name: DryRun
+ input: Paths
+ config:
+ table: "default.table"
+ catalog_properties:
+ type: "hadoop"
+ warehouse: "{TEMP_DIR}/dir"
+ schema_evolution_options: [ALLOW_FIELD_ADDITION]
+ dry_run: true
+ - type: WriteToJson
+ name: WriteReport
+ input: DryRun.dry_run_report
+ config:
+ path: "{TEMP_DIR}/report.json"
+ # The full report, as a real run against this input would decide it. The report is one
+ # row with nested fields; the two assertions below read it the way a user would: the
+ # verdict, the file counts and the table a real run would create (from the union, every
+ # column optional) come from the top level, the per-schema entries from an Explode.
+ - type: MapToFields
+ name: Verdict
+ input: DryRun.dry_run_report
+ config:
+ language: python
+ fields:
+ allowed: allowed
+ reason: reason
+ would_create_table: would_create_table
+ files_allowed: files_allowed
+ files_incompatible: files_incompatible
+ files_unreadable: files_unreadable
+ files_unchecked: files_unchecked
+ unchecked_registered: unchecked_registered
+ table_changes: table_changes
+ config_problems: config_problems
+ created_schema: created_table.schema
+ created_columns: created_table.columns
+ - type: AssertEqual
+ name: AssertVerdict
+ input: Verdict
+ config:
+ elements:
+ - allowed: true
+ reason: ""
+ would_create_table: true
+ files_allowed: 2
+ files_incompatible: 0
+ files_unreadable: 0
+ files_unchecked: 0
+ unchecked_registered: false
+ table_changes: []
+ config_problems: []
+ created_schema: '{{"type":"struct","schema-id":0,"fields":[{{"id":1,"name":"bool","required":false,"type":"boolean"}},{{"id":2,"name":"label","required":false,"type":"string"}},{{"id":3,"name":"rank","required":false,"type":"long"}}]}}'
+ created_columns: ["create optional bool boolean", "create optional label string", "create optional rank long"]
+ # one entry per distinct schema (canonical JSON), most common first
+ - type: Explode
+ name: Schemas
+ input: DryRun.dry_run_report
+ config:
+ fields: [schemas]
+ - type: MapToFields
+ name: SchemaEntries
+ input: Schemas
+ config:
+ language: python
+ fields:
+ schema_key: schemas.schema_key
+ schema: schemas.schema
+ num_files: schemas.num_files
+ changes: schemas.changes
+ allowed: schemas.allowed
+ reason: schemas.reason
+ - type: AssertEqual
+ name: AssertSchemas
+ input: SchemaEntries
+ config:
+ elements:
+ - schema_key: "s27e62d"
+ schema: '{{"type":"struct","schema-id":0,"fields":[{{"id":1,"name":"bool","required":true,"type":"boolean"}},{{"id":2,"name":"label","required":true,"type":"string"}},{{"id":3,"name":"rank","required":true,"type":"long"}}]}}'
+ num_files: 1
+ changes: []
+ allowed: true
+ reason: ""
+ - schema_key: "s48334d"
+ schema: '{{"type":"struct","schema-id":0,"fields":[{{"id":1,"name":"label","required":true,"type":"string"}},{{"id":2,"name":"rank","required":true,"type":"long"}}]}}'
+ num_files: 1
+ changes: []
+ allowed: true
+ reason: ""
+
+ providers:
+ - type: python
+ config: { }
+ transforms:
+ ReadMatchFiles: 'apache_beam.io.fileio.MatchFiles'
+
+ # Pipeline 4: the report was written (nested fields come back as strings through ReadFromJson,
+ # so only the scalar columns are compared here; the full row is asserted above)
+ - pipeline:
+ type: chain
+ transforms:
+ - type: ReadFromJson
+ config:
+ path: "{TEMP_DIR}/report.json*"
+ - type: MapToFields
+ config:
+ fields:
+ allowed: allowed
+ would_create_table: would_create_table
+ files_allowed: files_allowed
+ files_incompatible: files_incompatible
+ - type: AssertEqual
+ config:
+ elements:
+ - {allowed: true, would_create_table: true, files_allowed: 2, files_incompatible: 0}