This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 3d000b4e48e [fix](fe) Resolve dropped Iceberg equality-delete keys
after a schema ID is reused (#69536)
3d000b4e48e is described below
commit 3d000b4e48eff0386da476760c7546b806b7072f
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Mon Oct 26 07:03:48 2026 +0000
[fix](fe) Resolve dropped Iceberg equality-delete keys after a schema ID is
reused (#69536)
### What problem does this PR solve?
Issue Number: None
Related PR: #66538, #69394
Problem Summary:
**In short.** This ports a fix from branch-4.1 #66538 (Iceberg V3
default values). When a dropped column is still referenced by a live
equality delete, the Iceberg connector now looks up the column's
definition along the scan snapshot's parent chain first, and only then
in the whole schema list. Master searched only the part of the schema
list before the scan schema. That breaks as soon as Iceberg reuses a
schema ID, and every query on such a table failed.
**Background.** An equality delete names its key columns by field ID. If
a key column has since been dropped, the scan schema no longer has it,
so the connector adds the column's historical definition to the schema
dictionary it sends to BE
(`IcebergScanPlanProvider.schemaForPotentialEqualityDeletes`). BE needs
that field ID, type and initial default to read the key from data files.
Master (#65851) took the definition from the metadata schema list,
searching backwards from the scan schema's position, on the assumption
that the list is in chronological order.
**The problem, and what it cost.** Schema IDs are not a sequence. When a
schema change produces a schema equal to an existing one, Iceberg reuses
that schema's ID and does not append to the list. Dropping a column that
was added after the table was created is the common case: the schema
returns to the initial one, so the current schema is the first entry of
the list, and the schema that still defines the key comes after it. The
backward search never reaches that schema, and the scan fails.
Reproduce: create a table with `id`; add column `k`; write data and an
equality delete keyed on `k`; drop `k`; query the table.
| | Master today | After this PR |
|---|---|---|
| Query after dropping an equality-delete key, when the schema returns
to an earlier one | fails: `Iceberg equality-delete fields are absent
from schema history: [2]` | succeeds |
| Same, after the snapshots that recorded the key have expired | fails
with the same error | succeeds (the key comes from the metadata schema
list) |
| Time travel to a snapshot whose lineage defines the key, while a later
schema renames it | earlier definition | earlier definition (unchanged)
|
**How this PR fixes it.** It uses the same order as
`IcebergScanNode.getSchemaFieldsForScan` on branch-4.1 and branch-4.2.
First, walk the scan snapshot's parent chain and take each missing key
from the schema that snapshot was written with. Then search the whole
metadata schema list, newest first, for keys that only schema-only
changes or expired ancestors can explain. Unlike branch-4.x, the walk
indexes each schema at most once and stops as soon as every key is
resolved, so a long lineage that shares one schema costs only map
lookups. When no key is missing, nothing changes and no snapshot is
read, as before.
When a dropped key has several historical definitions, the one recorded
on the scan's lineage now wins, as on branch-4.x. The field ID and the
initial default are the same either way. Only a name changed by a
schema-only commit after the last write can differ. The existing test
`equalityCarrierKeepsLatestDefinitionOfDroppedEqualityKey` (from #69394)
renamed and then dropped the key with no write in between. It now writes
a snapshot after each schema change, as the branch-4.1 test it was
ported from does, and still expects `k2`. A dropped key nevertheless
keeps the compatible changes its field ID has in any retained schema:
the widest promotion of its type (the SDK's promotions such as INT→LONG,
plus the v3 promotions that Iceberg 1.12.0's
`TypeUtil.isPromotionAllowed` does not know: `unknown` to any primitive
type, and DATE to an unzoned TIMESTAMP or TIMESTAMP_NS), and optional
when any retained schema has it optional. The widest promotion does not
depend on the order of the schemas. If the retained schemas promote the
key to two types that do not promote to each other, which takes setting
the current schema back to an earlier one (for example DATE→TIMESTAMP,
back to DATE, then DATE→TIMESTAMP_NS), planning fails with an explicit
error: neither type holds the other's values exactly, and the metadata
does not record which one a delete file uses. A DATE initial default
moves to midnight in the promoted type, since Iceberg 1.12.0's
`DateLiteral` converts only to DATE. The carrier drops the write default
of a rebuilt field: no reader uses it, and a stale one may not fit the
promoted type. A delete committed after a schema-only promotion or
required→optional change follows the relaxed definition even when no
snapshot on the lineage records it: FileScannerV2 compares keys in the
carrier's type, and both readers reject a data file that lacks a
required key. A key is never narrowed, and a key required in every
retained schema stays required.
**Results.** This PR ports four tests from #66538 into
`IcebergScanPlanProviderTest`:
- `equalityCarrierResolvesDroppedKeyWhenSchemaIdIsReused` uses a real
in-memory table and also checks the scan dictionary that
`getScanNodeProperties` builds.
- `equalityCarrierResolvesReusedSchemaIdAfterParentSnapshotsExpire` is
the same, with the snapshots that recorded the key expired.
- `equalityCarrierPrefersScanLineageOverLaterRenameForTimeTravel` and
`equalityCarrierKeepsDefaultWhenTimeTravelParentExpiredBeforeLaterRename`
use table doubles with schema histories that the SDK cannot build.
- `equalityCarrierKeepsPromotedTypeOfKeyDroppedBeforeItsDeleteCommits`
(new) promotes a key from INT to LONG without a snapshot, prepares an
equality delete, drops the key and then commits the delete; the carrier
must say LONG. It fails without the widening.
- `equalityCarrierKeepsLineageTimestampOfPromotedDateKey` and
`equalityCarrierWidensDateKeyPromotedWithoutASnapshot` (new) promote a
v3 DATE key to TIMESTAMP / TIMESTAMP_NS, published directly since
`SchemaUpdate` in 1.12.0 rejects these promotions; the carrier must keep
the lineage's TIMESTAMP and widen a schema-only DATE→TIMESTAMP_NS.
`equalityCarrierKeepsSchemaOnlyOptionalityOfDroppedKey` and
`equalityCarrierKeepsAlwaysRequiredDroppedKeyRequired` (new) drop a
required key while a delete on it is pending, with and without a
schema-only `makeColumnOptional`; the carrier must be optional and
required respectively.
- `equalityCarrierConvertsDefaultsOfDateKeyPromotedToTimestamp` and
`equalityCarrierConvertsDefaultsOfDateKeyPromotedToTimestampNs` (new)
give the v3 DATE key an initial and a write default, promote it to
TIMESTAMP / TIMESTAMP_NS without a snapshot and drop it while a delete
on it is pending; the carrier must hold the initial default as midnight
in the promoted type and no write default, and the scan dictionary must
carry `2024-01-02 00:00:00`. Without the conversion, planning fails with
`Cannot cast default value to timestamp`.
- `equalityCarrierFollowsUnknownKeyPromotedWithoutASnapshot` (new)
promotes a v3 key from `unknown` to DATE and then TIMESTAMP_NS without
snapshots, and commits a pending append and a pending delete after the
key is dropped, so the lineage only records `unknown`; the carrier and
the scan dictionary must say TIMESTAMP_NS. Without the `unknown`
promotion the carrier stays `unknown`, which the dictionary sends as
STRING. `equalityCarrierDropsStaleWriteDefaultOfPromotedDateKey` (new)
records a DATE write default outside the TIMESTAMP_NS range, clears it
and promotes the key to TIMESTAMP_NS without snapshots; planning must
not fail on the stale write default, which converting fails with `long
overflow`.
- `equalityCarrierRejectsDateKeyPromotedToTimestampThenTimestampNs` and
`equalityCarrierRejectsDateKeyPromotedToTimestampNsThenTimestamp` (new)
promote a v3 DATE key to one of TIMESTAMP / TIMESTAMP_NS without a
snapshot, set the DATE schema current again, promote it to the other and
drop it while a delete on it is pending; planning must fail in either
order and name both types. Without the check the carrier takes whichever
type the schema list holds first, and a TIMESTAMP carrier rounds a
nanosecond tombstone to the microsecond.
`equalityCarrierRejectsUnknownKeyPromotedToUnrelatedTypes` (new) does
the same with `unknown` promoted to INT and then STRING.
`equalityCarrierKeepsLineagePromotionBesideAnAbandonedOne` (new) commits
the delete while TIMESTAMP_NS is current, so the lineage records
TIMESTAMP_NS; the abandoned TIMESTAMP schema must not fail planning.
With master's search, the two reused-ID tests fail with the error above.
Without the lineage walk, the time-travel test gets `k2` instead of `k`.
All 1706 tests of fe-connector-iceberg pass.
Co-authored-by: daidai <[email protected]>
---
.../connector/iceberg/IcebergScanPlanProvider.java | 157 +++++-
.../iceberg/IcebergScanPlanProviderTest.java | 536 ++++++++++++++++++++-
2 files changed, 670 insertions(+), 23 deletions(-)
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
index d3245f604d1..e4709175800 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
@@ -79,6 +79,7 @@ import org.apache.iceberg.exceptions.ValidationException;
import org.apache.iceberg.expressions.Expression;
import org.apache.iceberg.expressions.Expressions;
import org.apache.iceberg.expressions.InclusiveMetricsEvaluator;
+import org.apache.iceberg.expressions.Literal;
import org.apache.iceberg.expressions.ManifestEvaluator;
import org.apache.iceberg.expressions.Projections;
import org.apache.iceberg.expressions.ResidualEvaluator;
@@ -92,6 +93,7 @@ import org.apache.iceberg.types.Type;
import org.apache.iceberg.types.TypeUtil;
import org.apache.iceberg.types.Types;
import org.apache.iceberg.types.Types.NestedField;
+import org.apache.iceberg.util.DateTimeUtil;
import org.apache.iceberg.util.ScanTaskUtil;
import org.apache.iceberg.util.TableScanUtil;
import org.apache.logging.log4j.LogManager;
@@ -2108,7 +2110,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
// every branch so the default and current field type match BE's
read.
if (hasApplicableEqualityDeletes) {
List<NestedField> equalityFields =
schemaForPotentialEqualityDeletes(
- table, scanSchema, applicableEqualityDeleteFieldIds);
+ table, exactScan.snapshot(), scanSchema,
applicableEqualityDeleteFieldIds);
dict = IcebergSchemaUtils.encodeEqualitySchemaEvolutionProp(
table, equalityFields, appendRowLineage,
enableVarbinary, enableTimestampTz);
@@ -2280,29 +2282,27 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
* delete manifests does not enumerate data files or byte-split tasks, and
retained state is bounded by the
* number of equality keys rather than the table's entire schema history.
*
- * <p>The metadata schema list is searched in reverse chronology up to the
selected schema (schema IDs are
- * identifiers, not a sequence), so the latest definition of each stable
field ID wins. Current fields remain
- * first, allowing a dropped/re-added name to resolve the projected field
by name while a live historical
- * equality key resolves by its stable field ID.</p>
+ * <p>Schema IDs are identifiers, not a sequence: Iceberg reuses an ID
when evolution returns to an earlier
+ * schema, so a schema's position in the metadata list does not tell when
it was last active, and the list may
+ * also hold schemas committed after a time-travel or branch target. The
scan snapshot's parent chain is
+ * therefore searched first, so the definition active on that lineage
wins; the whole metadata list, newest
+ * first, is the fallback for schema-only changes and expired ancestors.
The selected definition keeps its name
+ * and initial default but takes the relaxations its field has in any
retained schema
+ * ({@link #withRetainedRelaxations}): a delete committed after a
schema-only promotion or required-to-optional
+ * change follows the relaxed definition even when no snapshot records it.
Current fields remain first,
+ * allowing a dropped/re-added name to resolve the projected field by name
while a live historical equality key
+ * resolves by its stable field ID.</p>
*/
@VisibleForTesting
static List<NestedField> schemaForPotentialEqualityDeletes(
Table table, TableScan scan, Schema scanSchema) {
return schemaForPotentialEqualityDeletes(
- table, scanSchema, applicableEqualityDeleteFieldIds(table,
scan));
+ table, scan.snapshot(), scanSchema,
applicableEqualityDeleteFieldIds(table, scan));
}
- private static List<NestedField> schemaForPotentialEqualityDeletes(
- Table table, Schema scanSchema, Set<Integer> equalityFieldIds) {
- List<Schema> metadataSchemas = metadataSchemaHistory(table);
- int selectedSchemaIndex = -1;
- for (int index = 0; index < metadataSchemas.size(); index++) {
- if (metadataSchemas.get(index).schemaId() ==
scanSchema.schemaId()) {
- selectedSchemaIndex = index;
- }
- }
- int lastRelevantIndex = selectedSchemaIndex >= 0
- ? selectedSchemaIndex : metadataSchemas.size() - 1;
+ @VisibleForTesting
+ static List<NestedField> schemaForPotentialEqualityDeletes(
+ Table table, Snapshot scanSnapshot, Schema scanSchema,
Set<Integer> equalityFieldIds) {
Set<Integer> missing = new HashSet<>(equalityFieldIds);
missing.removeAll(TypeUtil.indexById(scanSchema.asStruct()).keySet());
if (missing.isEmpty()) {
@@ -2310,8 +2310,24 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
}
List<NestedField> fields = new ArrayList<>(scanSchema.columns());
- for (int index = lastRelevantIndex; index >= 0 && !missing.isEmpty();
index--) {
- addHistoricalEqualityFields(fields, missing,
metadataSchemas.get(index));
+ List<Schema> metadataSchemas = metadataSchemaHistory(table);
+ // Snapshots on one lineage mostly share a schema, so each schema is
indexed at most once.
+ Set<Integer> lineageSchemaIds = new HashSet<>();
+ for (Snapshot snapshot = scanSnapshot; snapshot != null &&
!missing.isEmpty();
+ snapshot = snapshot.parentId() == null ? null :
table.snapshot(snapshot.parentId())) {
+ Integer schemaId = snapshot.schemaId();
+ if (schemaId == null || !lineageSchemaIds.add(schemaId)) {
+ continue;
+ }
+ Schema lineageSchema = table.schemas().get(schemaId);
+ if (lineageSchema == null) {
+ throw new IllegalStateException(
+ "Iceberg snapshot schema " + schemaId + " is absent
from table metadata");
+ }
+ addHistoricalEqualityFields(fields, missing, lineageSchema,
metadataSchemas);
+ }
+ for (int index = metadataSchemas.size() - 1; index >= 0 &&
!missing.isEmpty(); index--) {
+ addHistoricalEqualityFields(fields, missing,
metadataSchemas.get(index), metadataSchemas);
}
if (!missing.isEmpty()) {
throw new IllegalStateException(
@@ -2327,8 +2343,105 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
return new ArrayList<>(table.schemas().values());
}
- private static void addHistoricalEqualityFields(
- List<NestedField> fields, Set<Integer> missingFieldIds, Schema
historicalSchema) {
+ /**
+ * A writer commits with the schema that is current at commit, so a key
dropped before its delete commits loses
+ * the schema-only changes made before the drop from every snapshot. Field
IDs are never reused and a field
+ * only relaxes compatibly, by promotion or from required to optional, so
each field of the selected definition
+ * takes the widest promotion of its type ({@link #widestPromotion}) and
the optionality it has in any retained
+ * schema; neither depends on the order of the schemas. A field required
in every retained schema stays
+ * required; a rebuilt field keeps its initial default in the promoted
type ({@link #promotedDefault}) and drops
+ * its write default, which no reader uses and whose stale value may not
fit the promoted type.
+ */
+ private static List<NestedField> withRetainedRelaxations(List<NestedField>
fields, List<Schema> schemas) {
+ List<NestedField> relaxed = new ArrayList<>(fields.size());
+ for (NestedField field : fields) {
+ Type type = field.type();
+ boolean optional = field.isOptional();
+ List<Type.PrimitiveType> promotions = new ArrayList<>();
+ for (Schema schema : schemas) {
+ NestedField retained = schema.findField(field.fieldId());
+ if (retained == null) {
+ continue;
+ }
+ optional |= retained.isOptional();
+ if (type.isPrimitiveType() && retained.type().isPrimitiveType()
+ && isWideningPromotion(type.asPrimitiveType(),
retained.type().asPrimitiveType())) {
+ promotions.add(retained.type().asPrimitiveType());
+ }
+ }
+ if (type.isPrimitiveType()) {
+ type = widestPromotion(field, promotions);
+ }
+ if (type.isStructType()) {
+ type =
Types.StructType.of(withRetainedRelaxations(type.asStructType().fields(),
schemas));
+ }
+ relaxed.add(type.equals(field.type()) && optional ==
field.isOptional()
+ ? field :
Types.NestedField.from(field).ofType(type).isOptional(optional)
+
.withInitialDefault(promotedDefault(field.initialDefaultLiteral(),
field.type(), type))
+ .withWriteDefault((Literal<?>) null)
+ .build());
+ }
+ return relaxed;
+ }
+
+ /**
+ * The promotion of {@code field} that every other one in {@code
promotions} promotes to. Evolution moves a
+ * field only along promotions, so its retained promotions form a chain
unless the current schema was set back
+ * to an earlier one and the field was then promoted differently: DATE to
both TIMESTAMP and TIMESTAMP_NS, or
+ * UNKNOWN to two unrelated types. Neither type then holds the other's
values exactly, and the metadata does not
+ * record which one a delete file was written in, so planning fails
instead of comparing in either.
+ */
+ private static Type.PrimitiveType widestPromotion(NestedField field,
List<Type.PrimitiveType> promotions) {
+ Type.PrimitiveType widest = field.type().asPrimitiveType();
+ for (Type.PrimitiveType promotion : promotions) {
+ if (isWideningPromotion(widest, promotion)) {
+ widest = promotion;
+ }
+ }
+ for (Type.PrimitiveType promotion : promotions) {
+ if (!isWideningPromotion(promotion, widest)) {
+ throw new IllegalStateException("Iceberg equality-delete field
" + field.fieldId() + " ("
+ + field.name() + ") was promoted to both " + widest +
" and " + promotion
+ + " in schema history");
+ }
+ }
+ return widest;
+ }
+
+ /**
+ * {@link TypeUtil#isPromotionAllowed} plus the v3 promotions that the
SDK's schema update does not implement yet
+ * but the format allows: UNKNOWN to any primitive type, and DATE to an
unzoned TIMESTAMP or TIMESTAMP_NS.
+ */
+ private static boolean isWideningPromotion(Type.PrimitiveType from,
Type.PrimitiveType to) {
+ if (TypeUtil.isPromotionAllowed(from, to)) {
+ return true;
+ }
+ if (from.typeId() == Type.TypeID.UNKNOWN) {
+ return true;
+ }
+ if (from.typeId() != Type.TypeID.DATE) {
+ return false;
+ }
+ return to.typeId() == Type.TypeID.TIMESTAMP && !((Types.TimestampType)
to).shouldAdjustToUTC()
+ || to.typeId() == Type.TypeID.TIMESTAMP_NANO &&
!((Types.TimestampNanoType) to).shouldAdjustToUTC();
+ }
+
+ /**
+ * {@code value}, the initial default of a field of type {@code from}, in
the type {@code to} the field is rebuilt
+ * with. NestedField casts the initial default to its type, which converts
the literals of the SDK promotions,
+ * but in Iceberg 1.12.0 a DATE literal converts only to DATE: a DATE
default moves to midnight of its day in the
+ * promoted TIMESTAMP or TIMESTAMP_NS, and a date outside that type's
range throws, as the format requires.
+ */
+ private static Literal<?> promotedDefault(Literal<?> value, Type from,
Type to) {
+ if (value == null || from.typeId() != Type.TypeID.DATE ||
to.equals(from)) {
+ return value;
+ }
+ return Literal.of(DateTimeUtil.microsFromTimestamp(
+ DateTimeUtil.dateFromDays((Integer)
value.value()).atStartOfDay())).to(to);
+ }
+
+ private static void addHistoricalEqualityFields(List<NestedField> fields,
Set<Integer> missingFieldIds,
+ Schema historicalSchema, List<Schema> retainedSchemas) {
Map<Integer, NestedField> historicalFields =
TypeUtil.indexById(historicalSchema.asStruct());
Set<Integer> selectedFieldIds = new HashSet<>();
for (Integer fieldId : missingFieldIds) {
@@ -2345,7 +2458,7 @@ public class IcebergScanPlanProvider implements
ConnectorScanPlanProvider {
return;
}
Schema selectedSchema = TypeUtil.select(historicalSchema,
selectedFieldIds);
- mergeHistoricalEqualityFields(fields, selectedSchema.columns());
+ mergeHistoricalEqualityFields(fields,
withRetainedRelaxations(selectedSchema.columns(), retainedSchemas));
missingFieldIds.removeAll(selectedFieldIds);
}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
index b0a6866de3a..d997e826e05 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
@@ -50,6 +50,7 @@ import org.apache.doris.thrift.schema.external.TFieldPtr;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import org.apache.hadoop.conf.Configuration;
+import org.apache.iceberg.AppendFiles;
import org.apache.iceberg.BaseTable;
import org.apache.iceberg.DataFile;
import org.apache.iceberg.DataFiles;
@@ -62,6 +63,7 @@ import org.apache.iceberg.MetadataColumns;
import org.apache.iceberg.Metrics;
import org.apache.iceberg.PartitionData;
import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.RowDelta;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Snapshot;
import org.apache.iceberg.SnapshotSummary;
@@ -88,6 +90,7 @@ import org.apache.iceberg.mapping.NameMapping;
import org.apache.iceberg.mapping.NameMappingParser;
import org.apache.iceberg.metrics.ScanReport;
import org.apache.iceberg.types.Conversions;
+import org.apache.iceberg.types.Type;
import org.apache.iceberg.types.Types;
import org.apache.iceberg.types.Types.NestedField;
import org.junit.jupiter.api.Assertions;
@@ -108,6 +111,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Iterator;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
@@ -1925,7 +1929,8 @@ public class IcebergScanPlanProviderTest {
@Test
public void equalityCarrierKeepsLatestDefinitionOfDroppedEqualityKey()
throws Exception {
// BE fills an equality key that an old data file lacks from the key's
carrier entry, so a dropped key must
- // resolve to its latest historical definition: its last name, its
optionality and its initial default.
+ // resolve to the latest definition on the scan's lineage: its last
name, its optionality and its initial
+ // default. Each schema change is followed by a write, as on a table
that keeps ingesting.
Schema schema = new Schema(
Types.NestedField.required(1, "id", Types.IntegerType.get()),
Types.NestedField.optional("k").withId(2).ofType(Types.IntegerType.get())
@@ -1938,7 +1943,11 @@ public class IcebergScanPlanProviderTest {
table.newRowDelta().addDeletes(equalityDeleteFile(
"s3://b/db/renamed_dropped_eq_key/eq.parquet",
FileFormat.PARQUET, keyId)).commit();
table.updateSchema().renameColumn("k", "k2").commit();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/renamed_dropped_eq_key/f2.parquet", 1024, null,
null)).commit();
table.updateSchema().deleteColumn("k2").commit();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/renamed_dropped_eq_key/f3.parquet", 1024, null,
null)).commit();
IcebergScanPlanProvider provider = providerOver(table);
Map<String, String> props = provider.getScanNodeProperties(
@@ -2069,6 +2078,531 @@ public class IcebergScanPlanProviderTest {
Assertions.assertEquals(table.schema().columns(), carrier);
}
+ @Test
+ public void equalityCarrierResolvesDroppedKeyWhenSchemaIdIsReused() throws
Exception {
+ Schema schema = new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()));
+ Table table = createTable("reused_schema_id_eq_key", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
+ int initialSchemaId = table.schema().schemaId();
+ table.updateSchema().addColumn("k", Types.StringType.get()).commit();
+ int keyId = table.schema().findField("k").fieldId();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/reused_schema_id_eq_key/f1.parquet", 1024, null,
null)).commit();
+ table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/reused_schema_id_eq_key/eq.parquet",
FileFormat.PARQUET, keyId)).commit();
+ // Dropping k makes the schema equal to the initial one, so Iceberg
reuses the initial schema ID, which the
+ // metadata list holds before the schema that still defines the
equality key. The next snapshot carries the
+ // reused ID; only its parent records the schema with k.
+ table.updateSchema().deleteColumn("k").commit();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/reused_schema_id_eq_key/f2.parquet", 1024, null,
null)).commit();
+ Assertions.assertEquals(initialSchemaId, table.schema().schemaId());
+ Assertions.assertEquals(initialSchemaId,
table.currentSnapshot().schemaId());
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the dropped equality key
must stay resolvable: " + carrier);
+ Assertions.assertEquals(keyId, carrier.get(1).fieldId());
+ Assertions.assertEquals("k", carrier.get(1).name());
+
+ IcebergScanPlanProvider provider = providerOver(table);
+ Map<String, String> props = provider.getScanNodeProperties(
+ null, new IcebergTableHandle("db1", "reused_schema_id_eq_key"),
+ Collections.singletonList(new IcebergColumnHandle("id", 1)),
Optional.empty());
+ TFileScanRangeParams params = new TFileScanRangeParams();
+ provider.populateScanLevelParams(params, props);
+ List<TFieldPtr> fields =
params.getHistorySchemaInfo().get(0).getRootField().getFields();
+ Assertions.assertTrue(fields.stream().anyMatch(field ->
field.getFieldPtr().getId() == keyId),
+ "the scan dictionary must carry the dropped equality key after
the schema ID is reused");
+ }
+
+ @Test
+ public void
equalityCarrierKeepsPromotedTypeOfKeyDroppedBeforeItsDeleteCommits() throws
Exception {
+ // A writer started after a schema-only promotion of k to LONG writes
LONG tombstones, and k is dropped before
+ // its delete commits. The delete snapshot records the schema without
k and its parent the INT one, so only
+ // the metadata list knows LONG; an INT carrier would narrow
2147483648 to NULL and match NULL keys.
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", Types.IntegerType.get()));
+ Table table = createTable("promoted_dropped_eq_key", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/promoted_dropped_eq_key/f1.parquet", 1024, null,
null)).commit();
+ int intSchemaId = table.currentSnapshot().schemaId();
+ table.updateSchema().updateColumn("k", Types.LongType.get()).commit();
+ RowDelta pendingDelete =
table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/promoted_dropped_eq_key/eq.parquet",
FileFormat.PARQUET, 2));
+ table.updateSchema().deleteColumn("k").commit();
+ pendingDelete.commit();
+
Assertions.assertNull(table.schemas().get(table.currentSnapshot().schemaId()).findField(2));
+ Assertions.assertEquals(intSchemaId,
table.snapshot(table.currentSnapshot().parentId()).schemaId());
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the dropped equality key
must stay resolvable: " + carrier);
+ Assertions.assertEquals(2, carrier.get(1).fieldId());
+ Assertions.assertEquals("k", carrier.get(1).name());
+ Assertions.assertEquals(Types.LongType.get(), carrier.get(1).type());
+ }
+
+ @Test
+ public void equalityCarrierKeepsLineageTimestampOfPromotedDateKey() throws
Exception {
+ // v3 promotes DATE to an unzoned TIMESTAMP, which
TypeUtil.isPromotionAllowed does not know. The delete
+ // snapshot records the TIMESTAMP schema; a DATE carrier would
truncate a noon tombstone to its date and
+ // delete the midnight row.
+ Table table = createTable("promoted_date_lineage_eq_key", new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", Types.DateType.get())),
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/promoted_date_lineage_eq_key/f1.parquet", 1024,
null, null)).commit();
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k",
Types.TimestampType.withoutZone())), 2);
+ table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/promoted_date_lineage_eq_key/eq.parquet",
FileFormat.PARQUET, 2)).commit();
+ table.updateSchema().deleteColumn("k").commit();
+ Assertions.assertEquals(Types.TimestampType.withoutZone(),
+
table.schemas().get(table.currentSnapshot().schemaId()).findType(2));
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the dropped equality key
must stay resolvable: " + carrier);
+ Assertions.assertEquals(Types.TimestampType.withoutZone(),
carrier.get(1).type());
+ }
+
+ @Test
+ public void equalityCarrierWidensDateKeyPromotedWithoutASnapshot() throws
Exception {
+ // A writer started after a schema-only v3 promotion of k to
TIMESTAMP_NS writes TIMESTAMP_NS tombstones,
+ // and k is dropped before its delete commits, so only the metadata
list knows the promotion.
+ Table table = createTable("promoted_date_dropped_eq_key", new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", Types.DateType.get())),
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/promoted_date_dropped_eq_key/f1.parquet", 1024,
null, null)).commit();
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k",
Types.TimestampNanoType.withoutZone())), 2);
+ RowDelta pendingDelete =
table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/promoted_date_dropped_eq_key/eq.parquet",
FileFormat.PARQUET, 2));
+ table.updateSchema().deleteColumn("k").commit();
+ pendingDelete.commit();
+ Assertions.assertEquals(Types.DateType.get(), table.schemas()
+
.get(table.snapshot(table.currentSnapshot().parentId()).schemaId()).findType(2));
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the dropped equality key
must stay resolvable: " + carrier);
+ Assertions.assertEquals(Types.TimestampNanoType.withoutZone(),
carrier.get(1).type());
+ }
+
+ @Test
+ public void equalityCarrierFollowsUnknownKeyPromotedWithoutASnapshot()
throws Exception {
+ // v3 lets UNKNOWN become any type, which TypeUtil.isPromotionAllowed
does not know. k is promoted to DATE and
+ // then TIMESTAMP_NS without snapshots, and a pending append and a
pending delete commit after k is dropped,
+ // so the only snapshot that records k has it as UNKNOWN. An UNKNOWN
carrier is sent as STRING, and V2 would
+ // compare 2024-01-02 with 2024-01-02 00:00:00.000000000 and keep the
deleted row.
+ Table table = createTable("promoted_unknown_dropped_eq_key", new
Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", Types.UnknownType.get())),
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/promoted_unknown_dropped_eq_key/f1.parquet", 1024,
null, null)).commit();
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", Types.DateType.get())), 2);
+ AppendFiles pendingAppend =
table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/promoted_unknown_dropped_eq_key/f2.parquet", 1024,
null, null));
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k",
Types.TimestampNanoType.withoutZone())), 2);
+ RowDelta pendingDelete =
table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/promoted_unknown_dropped_eq_key/eq.parquet",
FileFormat.PARQUET, 2));
+ table.updateSchema().deleteColumn("k").commit();
+ pendingAppend.commit();
+ pendingDelete.commit();
+ Assertions.assertEquals(Types.UnknownType.get(),
table.schemas().get(table.snapshot(table.snapshot(
+
table.currentSnapshot().parentId()).parentId()).schemaId()).findType(2));
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the dropped equality key
must stay resolvable: " + carrier);
+ Assertions.assertEquals(Types.TimestampNanoType.withoutZone(),
carrier.get(1).type());
+ Assertions.assertTrue(carrier.get(1).isOptional(), carrier.toString());
+
+ IcebergScanPlanProvider provider = providerOver(table);
+ Map<String, String> props = provider.getScanNodeProperties(null,
+ new IcebergTableHandle("db1",
"promoted_unknown_dropped_eq_key"),
+ Collections.singletonList(new IcebergColumnHandle("id", 1)),
Optional.empty());
+ Assertions.assertEquals(TPrimitiveType.TIMESTAMP_NS,
+ dictFieldById(dictRootFields(provider, props),
2).getType().getType());
+ }
+
+ @Test
+ public void equalityCarrierConvertsDefaultsOfDateKeyPromotedToTimestamp()
throws Exception {
+ assertPromotedDateKeyDefaults("promoted_date_default_eq_key",
Types.TimestampType.withoutZone());
+ }
+
+ @Test
+ public void
equalityCarrierConvertsDefaultsOfDateKeyPromotedToTimestampNs() throws
Exception {
+ assertPromotedDateKeyDefaults("promoted_date_nanos_default_eq_key",
Types.TimestampNanoType.withoutZone());
+ }
+
+ /**
+ * Gives a v3 DATE key both defaults, promotes it to {@code promotedType}
without a snapshot and drops it while an
+ * equality delete on it is pending. The lineage selects the DATE
definition and the carrier rebuilds it in the
+ * promoted type, which casts its initial default; a DATE literal converts
only to DATE, so an unconverted
+ * initial default fails planning with "Cannot cast default value". The
rebuilt field has no write default.
+ */
+ private static void assertPromotedDateKeyDefaults(String name, Type
promotedType) {
+ Literal<Integer> date =
Literal.of("2024-01-02").to(Types.DateType.get());
+ Literal<Long> midnight =
Literal.of("2024-01-02T00:00:00").to(promotedType);
+ Table table = createTable(name, new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+
Types.NestedField.optional("k").withId(2).ofType(Types.DateType.get())
+
.withInitialDefault(date).withWriteDefault(date).build()),
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/" +
name + "/f1.parquet", 1024, null, null))
+ .commit();
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional("k").withId(2).ofType(promotedType)
+
.withInitialDefault(midnight).withWriteDefault(midnight).build()), 2);
+ RowDelta pendingDelete =
table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/" + name + "/eq.parquet", FileFormat.PARQUET, 2));
+ table.updateSchema().deleteColumn("k").commit();
+ pendingDelete.commit();
+ Assertions.assertEquals(Types.DateType.get(), table.schemas()
+
.get(table.snapshot(table.currentSnapshot().parentId()).schemaId()).findType(2));
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the dropped equality key
must stay resolvable: " + carrier);
+ Assertions.assertEquals(promotedType, carrier.get(1).type());
+ Assertions.assertEquals(midnight,
carrier.get(1).initialDefaultLiteral());
+ Assertions.assertNull(carrier.get(1).writeDefaultLiteral());
+
+ IcebergScanPlanProvider provider = providerOver(table);
+ Map<String, String> props = provider.getScanNodeProperties(null, new
IcebergTableHandle("db1", name),
+ Collections.singletonList(new IcebergColumnHandle("id", 1)),
Optional.empty());
+ Assertions.assertEquals("2024-01-02 00:00:00",
+ dictFieldById(dictRootFields(provider, props),
2).getInitialDefaultValue());
+ }
+
+ @Test
+ public void equalityCarrierDropsStaleWriteDefaultOfPromotedDateKey()
throws Exception {
+ // The snapshot that records k has a DATE write default outside the
TIMESTAMP_NS range. The write default is
+ // cleared and k is promoted to TIMESTAMP_NS without snapshots, then k
is dropped while a delete on it is
+ // pending. No reader uses a write default, so the carrier must not
convert the stale one, which overflows.
+ Literal<Integer> outOfRange =
Literal.of("1000-01-01").to(Types.DateType.get());
+ Table table = createTable("stale_write_default_eq_key", new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+
Types.NestedField.optional("k").withId(2).ofType(Types.DateType.get())
+ .withWriteDefault(outOfRange).build()),
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/stale_write_default_eq_key/f1.parquet", 1024, null,
null)).commit();
+ table.updateSchema().updateColumnDefault("k", null).commit();
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k",
Types.TimestampNanoType.withoutZone())), 2);
+ RowDelta pendingDelete =
table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/stale_write_default_eq_key/eq.parquet",
FileFormat.PARQUET, 2));
+ table.updateSchema().deleteColumn("k").commit();
+ pendingDelete.commit();
+ Assertions.assertEquals(outOfRange.value(), table.schemas()
+
.get(table.snapshot(table.currentSnapshot().parentId()).schemaId()).findField(2).writeDefault());
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the dropped equality key
must stay resolvable: " + carrier);
+ Assertions.assertEquals(Types.TimestampNanoType.withoutZone(),
carrier.get(1).type());
+ Assertions.assertNull(carrier.get(1).initialDefaultLiteral());
+ Assertions.assertNull(carrier.get(1).writeDefaultLiteral());
+
+ IcebergScanPlanProvider provider = providerOver(table);
+ Map<String, String> props = provider.getScanNodeProperties(null,
+ new IcebergTableHandle("db1", "stale_write_default_eq_key"),
+ Collections.singletonList(new IcebergColumnHandle("id", 1)),
Optional.empty());
+ Assertions.assertFalse(dictFieldById(dictRootFields(provider, props),
2).isSetInitialDefaultValue());
+ }
+
+ @Test
+ public void
equalityCarrierRejectsDateKeyPromotedToTimestampThenTimestampNs() throws
Exception {
+ assertUnrelatedDateKeyPromotionsFailPlanning("date_ts_then_ns_eq_key",
Types.TimestampType.withoutZone(),
+ Types.TimestampNanoType.withoutZone());
+ }
+
+ @Test
+ public void
equalityCarrierRejectsDateKeyPromotedToTimestampNsThenTimestamp() throws
Exception {
+ assertUnrelatedDateKeyPromotionsFailPlanning("date_ns_then_ts_eq_key",
Types.TimestampNanoType.withoutZone(),
+ Types.TimestampType.withoutZone());
+ }
+
+ /**
+ * Promotes a v3 DATE key to {@code first} without a snapshot, sets the
DATE schema current again (publishing the
+ * identical schema reuses its ID), promotes the key to {@code second} and
drops it while an equality delete on
+ * it is pending. TIMESTAMP and TIMESTAMP_NS do not promote to each other
and the metadata does not record which
+ * one the delete was written in, so planning must fail in either order: a
TIMESTAMP carrier would round the
+ * tombstone 2024-01-02T00:00:00.000000001 to midnight and delete the DATE
row.
+ */
+ private static void assertUnrelatedDateKeyPromotionsFailPlanning(String
name, Type first, Type second) {
+ Schema dateSchema = new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", Types.DateType.get()));
+ Table table = createTable(name, dateSchema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/" +
name + "/f1.parquet", 1024, null, null))
+ .commit();
+ int dateSchemaId = table.schema().schemaId();
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", first)), 2);
+ publishSchema(table, dateSchema, 2);
+ Assertions.assertEquals(dateSchemaId, table.schema().schemaId());
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", second)), 2);
+ RowDelta pendingDelete =
table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/" + name + "/eq.parquet", FileFormat.PARQUET, 2));
+ table.updateSchema().deleteColumn("k").commit();
+ pendingDelete.commit();
+ Assertions.assertEquals(Types.DateType.get(), table.schemas()
+
.get(table.snapshot(table.currentSnapshot().parentId()).schemaId()).findType(2));
+
+ IllegalStateException error =
Assertions.assertThrows(IllegalStateException.class, () ->
+
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(table,
table.newScan(), table.schema()));
+ Assertions.assertEquals("Iceberg equality-delete field 2 (k) was
promoted to both " + first + " and "
+ + second + " in schema history", error.getMessage());
+ IcebergScanPlanProvider provider = providerOver(table);
+ Assertions.assertThrows(IllegalStateException.class, () ->
provider.getScanNodeProperties(null,
+ new IcebergTableHandle("db1", name),
Collections.singletonList(new IcebergColumnHandle("id", 1)),
+ Optional.empty()));
+ }
+
+ @Test
+ public void equalityCarrierKeepsLineagePromotionBesideAnAbandonedOne()
throws Exception {
+ // The schemas of the rollback history above, but the delete commits
while TIMESTAMP_NS is current, so the
+ // lineage records TIMESTAMP_NS. The abandoned TIMESTAMP schema is not
a promotion of that definition and
+ // must not fail planning.
+ Schema dateSchema = new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", Types.DateType.get()));
+ Table table = createTable("date_ns_lineage_beside_ts_eq_key",
dateSchema, PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/date_ns_lineage_beside_ts_eq_key/f1.parquet", 1024,
null, null)).commit();
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k",
Types.TimestampType.withoutZone())), 2);
+ publishSchema(table, dateSchema, 2);
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k",
Types.TimestampNanoType.withoutZone())), 2);
+ table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/date_ns_lineage_beside_ts_eq_key/eq.parquet",
FileFormat.PARQUET, 2)).commit();
+ table.updateSchema().deleteColumn("k").commit();
+ Assertions.assertEquals(Types.TimestampNanoType.withoutZone(),
+
table.schemas().get(table.currentSnapshot().schemaId()).findType(2));
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the dropped equality key
must stay resolvable: " + carrier);
+ Assertions.assertEquals(Types.TimestampNanoType.withoutZone(),
carrier.get(1).type());
+ }
+
+ @Test
+ public void equalityCarrierRejectsUnknownKeyPromotedToUnrelatedTypes()
throws Exception {
+ // v3 lets UNKNOWN become any type. k is promoted to INT, set back to
UNKNOWN and promoted to STRING without
+ // snapshots, so INT and STRING are both promotions of the lineage's
UNKNOWN. An INT carrier would turn a
+ // non-numeric STRING tombstone into NULL, which matches every row
written while k was UNKNOWN.
+ Schema unknownSchema = new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", Types.UnknownType.get()));
+ Table table = createTable("unknown_int_then_string_eq_key",
unknownSchema, PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/unknown_int_then_string_eq_key/f1.parquet", 1024,
null, null)).commit();
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", Types.IntegerType.get())),
2);
+ publishSchema(table, unknownSchema, 2);
+ publishSchema(table, new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()),
+ Types.NestedField.optional(2, "k", Types.StringType.get())),
2);
+ RowDelta pendingDelete =
table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/unknown_int_then_string_eq_key/eq.parquet",
FileFormat.PARQUET, 2));
+ table.updateSchema().deleteColumn("k").commit();
+ pendingDelete.commit();
+ Assertions.assertEquals(Types.UnknownType.get(), table.schemas()
+
.get(table.snapshot(table.currentSnapshot().parentId()).schemaId()).findType(2));
+
+ IllegalStateException error =
Assertions.assertThrows(IllegalStateException.class, () ->
+
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(table,
table.newScan(), table.schema()));
+ Assertions.assertEquals("Iceberg equality-delete field 2 (k) was
promoted to both int and string"
+ + " in schema history", error.getMessage());
+ }
+
+ @Test
+ public void equalityCarrierKeepsSchemaOnlyOptionalityOfDroppedKey() throws
Exception {
+ // k becomes optional without a snapshot, and both a pending delete
and an append without k commit after k
+ // is dropped, so every snapshot on the lineage records either no k or
the required one. A required carrier
+ // would make both readers reject the appended file for a missing
required field.
+ Table table = droppedRequiredKeyTable("optional_dropped_eq_key", true);
+
Assertions.assertTrue(table.schemas().get(table.snapshot(table.snapshot(
+
table.currentSnapshot().parentId()).parentId()).schemaId()).findField(2).isRequired());
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the dropped equality key
must stay resolvable: " + carrier);
+ Assertions.assertTrue(carrier.get(1).isOptional(), carrier.toString());
+ Assertions.assertEquals(Types.IntegerType.get(),
carrier.get(1).type());
+ }
+
+ @Test
+ public void equalityCarrierKeepsAlwaysRequiredDroppedKeyRequired() throws
Exception {
+ Table table = droppedRequiredKeyTable("required_dropped_eq_key",
false);
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the dropped equality key
must stay resolvable: " + carrier);
+ Assertions.assertTrue(carrier.get(1).isRequired(), carrier.toString());
+ }
+
+ /**
+ * Appends a file with a required k, optionally makes k optional without a
snapshot, then drops k while an
+ * equality delete on it is pending and commits an append without k before
the delete.
+ */
+ private static Table droppedRequiredKeyTable(String name, boolean
makeOptional) {
+ Table table = createTable(name, new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.required(2, "k", Types.IntegerType.get())),
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
+ table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/" +
name + "/f1.parquet", 1024, null, null))
+ .commit();
+ if (makeOptional) {
+ table.updateSchema().makeColumnOptional("k").commit();
+ }
+ RowDelta pendingDelete =
table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/" + name + "/eq.parquet", FileFormat.PARQUET, 2));
+ table.updateSchema().deleteColumn("k").commit();
+ table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/" +
name + "/f2.parquet", 1024, null, null))
+ .commit();
+ pendingDelete.commit();
+
Assertions.assertNull(table.schemas().get(table.currentSnapshot().schemaId()).findField(2));
+ return table;
+ }
+
+ /** Publishes a schema directly: SchemaUpdate in Iceberg 1.12.0 rejects
the v3 DATE promotions. */
+ private static void publishSchema(Table table, Schema schema, int
lastColumnId) {
+ org.apache.iceberg.TableOperations operations =
((org.apache.iceberg.HasTableOperations) table).operations();
+ org.apache.iceberg.TableMetadata base = operations.current();
+ operations.commit(base,
org.apache.iceberg.TableMetadata.buildFrom(base)
+ .setCurrentSchema(schema, lastColumnId).build());
+ table.refresh();
+ }
+
+ @Test
+ public void
equalityCarrierResolvesReusedSchemaIdAfterParentSnapshotsExpire() throws
Exception {
+ Schema schema = new Schema(Types.NestedField.required(1, "id",
Types.IntegerType.get()));
+ Table table = createTable("reused_schema_id_expired_parent", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
+ table.updateSchema().addColumn("k", Types.StringType.get()).commit();
+ int keyId = table.schema().findField("k").fieldId();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/reused_schema_id_expired_parent/f1.parquet", 1024,
null, null)).commit();
+ long appendSnapshotId = table.currentSnapshot().snapshotId();
+ table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/reused_schema_id_expired_parent/eq.parquet",
FileFormat.PARQUET, keyId)).commit();
+ long deleteSnapshotId = table.currentSnapshot().snapshotId();
+ table.updateSchema().deleteColumn("k").commit();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/reused_schema_id_expired_parent/f2.parquet", 1024,
null, null)).commit();
+ // With the snapshots that recorded k expired, the scan lineage ends
at the reused-ID snapshot, so the key
+ // can only come from the metadata schema list, where it sits after
the reused schema.
+
table.expireSnapshots().expireSnapshotId(appendSnapshotId).expireSnapshotId(deleteSnapshotId)
+ .cleanExpiredFiles(false).commit();
+
Assertions.assertNull(table.snapshot(table.currentSnapshot().parentId()));
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the dropped equality key
must stay resolvable: " + carrier);
+ Assertions.assertEquals(keyId, carrier.get(1).fieldId());
+ Assertions.assertEquals("k", carrier.get(1).name());
+ }
+
+ @Test
+ public void
equalityCarrierPrefersScanLineageOverLaterRenameForTimeTravel() {
+ // The metadata list also holds schemas committed after a time-travel
target. A later schema that renames
+ // the key must not win over the definition recorded on the target's
own lineage.
+ NestedField id = Types.NestedField.required(1, "id",
Types.LongType.get());
+ Schema schemaWithEqualityKey = new Schema(100, Arrays.asList(id,
intKeyWithDefault("k")));
+ Schema timeTravelSchema = new Schema(1, Collections.singletonList(id));
+ Schema laterSchema = new Schema(2, Arrays.asList(id,
intKeyWithDefault("k2")));
+ Snapshot timeTravelSnapshot = snapshotOf(1001L, 1000L,
timeTravelSchema);
+ Table table = tableWithHistory(Arrays.asList(schemaWithEqualityKey,
timeTravelSchema, laterSchema),
+ snapshotOf(1000L, null, schemaWithEqualityKey),
timeTravelSnapshot);
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, timeTravelSnapshot, timeTravelSchema,
Collections.singleton(7));
+ Assertions.assertEquals(2, carrier.size());
+ Assertions.assertEquals(7, carrier.get(1).fieldId());
+ Assertions.assertEquals("k", carrier.get(1).name());
+ }
+
+ @Test
+ public void
equalityCarrierKeepsDefaultWhenTimeTravelParentExpiredBeforeLaterRename() {
+ // Once the lineage that recorded the key has expired, the metadata
list is the only source left. Its newest
+ // definition may come from a later rename, but the field ID and its
initial default stay the same.
+ NestedField id = Types.NestedField.required(1, "id",
Types.LongType.get());
+ Schema schemaWithEqualityKey = new Schema(100, Arrays.asList(id,
intKeyWithDefault("k")));
+ Schema timeTravelSchema = new Schema(1, Collections.singletonList(id));
+ Schema laterSchema = new Schema(2, Arrays.asList(id,
intKeyWithDefault("k2")));
+ Snapshot timeTravelSnapshot = snapshotOf(1001L, 1000L,
timeTravelSchema);
+ Table table = tableWithHistory(Arrays.asList(schemaWithEqualityKey,
timeTravelSchema, laterSchema),
+ timeTravelSnapshot);
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, timeTravelSnapshot, timeTravelSchema,
Collections.singleton(7));
+ Assertions.assertEquals(2, carrier.size());
+ Assertions.assertEquals(7, carrier.get(1).fieldId());
+ Assertions.assertEquals(7, carrier.get(1).initialDefault());
+ }
+
+ private static NestedField intKeyWithDefault(String name) {
+ return
Types.NestedField.optional(name).withId(7).ofType(Types.IntegerType.get())
+ .withInitialDefault(7).build();
+ }
+
+ /** A snapshot double exposing only its lineage and the ID of the schema
it was written with. */
+ private static Snapshot snapshotOf(long snapshotId, Long parentId, Schema
schema) {
+ return (Snapshot)
Proxy.newProxyInstance(Snapshot.class.getClassLoader(), new Class<?>[]
{Snapshot.class},
+ (proxy, method, args) -> {
+ switch (method.getName()) {
+ case "snapshotId":
+ return snapshotId;
+ case "parentId":
+ return parentId;
+ case "schemaId":
+ return schema.schemaId();
+ default:
+ throw new
UnsupportedOperationException(method.getName());
+ }
+ });
+ }
+
+ /**
+ * A table double exposing only schema history, in metadata-list order,
and the retained snapshots. It allows
+ * schema histories that the SDK cannot build, such as a later schema that
renames a dropped field ID.
+ */
+ private static Table tableWithHistory(List<Schema> schemas, Snapshot...
snapshots) {
+ Map<Integer, Schema> schemasById = new LinkedHashMap<>();
+ for (Schema schema : schemas) {
+ schemasById.put(schema.schemaId(), schema);
+ }
+ Map<Long, Snapshot> snapshotsById = new HashMap<>();
+ for (Snapshot snapshot : snapshots) {
+ snapshotsById.put(snapshot.snapshotId(), snapshot);
+ }
+ return (Table) Proxy.newProxyInstance(Table.class.getClassLoader(),
new Class<?>[] {Table.class},
+ (proxy, method, args) -> {
+ switch (method.getName()) {
+ case "schemas":
+ return schemasById;
+ case "snapshot":
+ return snapshotsById.get((Long) args[0]);
+ default:
+ throw new
UnsupportedOperationException(method.getName());
+ }
+ });
+ }
+
@Test
public void
partitionPrunedEqualityDeleteConservativelyRequiresCurrentBackendSemantics() {
PartitionSpec spec =
PartitionSpec.builderFor(PART_SCHEMA).identity("p").build();
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]