This is an automated email from the ASF dual-hosted git repository.
ahmedabu98 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new 369409ea492 [IcebergIO] Serialize using json partition (#39705)
369409ea492 is described below
commit 369409ea4920e62ef332444b6684544d5b66ea07
Author: Ahmed Abualsaud <[email protected]>
AuthorDate: Thu Aug 13 13:59:22 2026 -0700
[IcebergIO] Serialize using json partition (#39705)
* use json partitioning instead
* trigger ITs
---
.../IO_Iceberg_Integration_Tests.json | 2 +-
.../org/apache/beam/sdk/io/iceberg/AddFiles.java | 2 +-
.../apache/beam/sdk/io/iceberg/PartitionUtils.java | 10 +-
.../beam/sdk/io/iceberg/RecordWriterManager.java | 9 +-
.../beam/sdk/io/iceberg/SerializableDataFile.java | 88 ++++++++++--
.../io/iceberg/WritePartitionedRowsToFiles.java | 3 +-
.../beam/sdk/io/iceberg/cdc/CdcReadUtils.java | 2 +-
.../io/iceberg/cdc/SerializableChangelogTask.java | 9 +-
.../beam/sdk/io/iceberg/PartitionUtilsTest.java | 10 +-
.../sdk/io/iceberg/RecordWriterManagerTest.java | 24 +---
.../sdk/io/iceberg/SerializableDataFileTest.java | 156 ++++++++++++++++++++-
.../beam/sdk/io/iceberg/cdc/CdcReadUtilsTest.java | 2 +-
.../sdk/io/iceberg/cdc/ChangelogScannerTest.java | 2 +-
.../sdk/io/iceberg/cdc/LocalResolveDoFnTest.java | 2 +-
.../sdk/io/iceberg/cdc/ReadFromChangelogsTest.java | 2 +-
.../iceberg/cdc/SerializableChangelogTaskTest.java | 2 +-
16 files changed, 259 insertions(+), 66 deletions(-)
diff --git a/.github/trigger_files/IO_Iceberg_Integration_Tests.json
b/.github/trigger_files/IO_Iceberg_Integration_Tests.json
index b73af5e61a4..7ab7bcd9a9c 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": 1
+ "modification": 2
}
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 f37935f89e8..18b95ca50b1 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
@@ -514,7 +514,7 @@ public class AddFiles extends
PTransform<PCollection<String>, PCollectionRowTupl
.withPartitionPath(partitionPath)
.build();
return new ProcessResult(
- SerializableDataFile.from(df, partitionPath), null, timestamp,
window, paneInfo);
+ SerializableDataFile.from(df, table.spec()), null, timestamp,
window, paneInfo);
};
}
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/PartitionUtils.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/PartitionUtils.java
index 32a25439d85..6c8e79c4b8c 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/PartitionUtils.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/PartitionUtils.java
@@ -154,7 +154,7 @@ class PartitionUtils {
* {@link ContentScanTask}s.
*/
public static Map<Integer, ?> constantsMap(
- PartitionSpec spec, ContentFile<?> file, @Nullable Long
fileSequenceNumber) {
+ PartitionSpec spec, ContentFile<?> file, @Nullable Long
dataSequenceNumber) {
Preconditions.checkState(
spec.specId() == file.specId(),
"File spec ID (%s) does not match PartitionSpec ID (%s)",
@@ -172,13 +172,13 @@ class PartitionUtils {
convertConstant(Types.LongType.get(), file.firstRowId()));
}
- // When reconstructing a DataFile, we lose the ability to attach its
fileSequenceNumber,
+ // When reconstructing a DataFile, we lose the ability to attach its
dataSequenceNumber,
// so we pipe it along the util methods to include it here.
- fileSequenceNumber =
- fileSequenceNumber != null ? fileSequenceNumber :
file.fileSequenceNumber();
+ dataSequenceNumber =
+ dataSequenceNumber != null ? dataSequenceNumber :
file.dataSequenceNumber();
idToConstant.put(
MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId(),
- convertConstant(Types.LongType.get(), fileSequenceNumber));
+ convertConstant(Types.LongType.get(), dataSequenceNumber));
// add _file
idToConstant.put(
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/RecordWriterManager.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/RecordWriterManager.java
index 6893c743f43..64f0ab6232a 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/RecordWriterManager.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/RecordWriterManager.java
@@ -104,7 +104,6 @@ class RecordWriterManager implements AutoCloseable {
final Cache<PartitionKey, RecordWriter> writers;
private final List<SerializableDataFile> dataFiles = Lists.newArrayList();
@VisibleForTesting final Map<PartitionKey, Integer> writerCounts =
Maps.newHashMap();
- private final Map<String, PartitionField> partitionFieldMap =
Maps.newHashMap();
private final List<Exception> exceptions = Lists.newArrayList();
private final InternalRecordWrapper wrapper; // wrapper that facilitates
partitioning
@@ -115,9 +114,6 @@ class RecordWriterManager implements AutoCloseable {
this.routingPartitionKey = new PartitionKey(spec, schema);
this.wrapper = new InternalRecordWrapper(schema.asStruct());
this.table = table;
- for (PartitionField partitionField : spec.fields()) {
- partitionFieldMap.put(partitionField.name(), partitionField);
- }
// build a cache of RecordWriters.
// writers will expire after 1 min of idle time.
@@ -127,7 +123,6 @@ class RecordWriterManager implements AutoCloseable {
.expireAfterAccess(1, TimeUnit.MINUTES)
.removalListener(
(RemovalNotification<PartitionKey, RecordWriter> removal) ->
{
- final PartitionKey pk =
Preconditions.checkStateNotNull(removal.getKey());
final RecordWriter recordWriter =
Preconditions.checkStateNotNull(removal.getValue());
try {
@@ -144,9 +139,9 @@ class RecordWriterManager implements AutoCloseable {
throw rethrow;
}
openWriters--;
- String partitionPath = getPartitionDataPath(pk.toPath(),
partitionFieldMap);
+ // Serialize against the file's own spec (looked up by its
spec id)
dataFiles.add(
- SerializableDataFile.from(recordWriter.getDataFile(),
partitionPath));
+ SerializableDataFile.from(recordWriter.getDataFile(),
table.specs()));
})
.build();
}
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SerializableDataFile.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SerializableDataFile.java
index e1291601d14..01d50010606 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SerializableDataFile.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SerializableDataFile.java
@@ -26,9 +26,11 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import org.apache.beam.sdk.annotations.Internal;
import org.apache.beam.sdk.schemas.AutoValueSchema;
import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
import org.apache.beam.sdk.schemas.annotations.SchemaFieldNumber;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Equivalence;
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Maps;
import org.apache.iceberg.DataFile;
@@ -37,6 +39,8 @@ import org.apache.iceberg.FileFormat;
import org.apache.iceberg.Metrics;
import org.apache.iceberg.PartitionKey;
import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.SingleValueParser;
+import org.apache.iceberg.StructLike;
import org.checkerframework.checker.nullness.qual.Nullable;
/**
@@ -49,11 +53,12 @@ import org.checkerframework.checker.nullness.qual.Nullable;
* <p>NOTE: If you add any new fields here, you need to also update the {@link
#equals} and {@link
* #hashCode()} methods.
*
- * <p>Use {@link #from(DataFile, String)} to create a {@link
SerializableDataFile} and {@link
+ * <p>Use {@link #from(DataFile, PartitionSpec)} to create a {@link
SerializableDataFile} and {@link
* #createDataFile(Map)} to reconstruct the original {@link DataFile}.
*/
@DefaultSchema(AutoValueSchema.class)
@AutoValue
+@Internal
public abstract class SerializableDataFile {
public static Builder builder() {
return new AutoValue_SerializableDataFile.Builder();
@@ -71,7 +76,9 @@ public abstract class SerializableDataFile {
@SchemaFieldNumber("3")
public abstract long getFileSizeInBytes();
+ /** @deprecated Use {@link #getJsonPartition()} instead. */
@SchemaFieldNumber("4")
+ @Deprecated
public abstract String getPartitionPath();
@SchemaFieldNumber("5")
@@ -110,6 +117,9 @@ public abstract class SerializableDataFile {
@SchemaFieldNumber("16")
public abstract @Nullable Long getFirstRowId();
+ @SchemaFieldNumber("17")
+ abstract @Nullable String getJsonPartition();
+
@AutoValue.Builder
public abstract static class Builder {
abstract Builder setPath(String path);
@@ -122,6 +132,8 @@ public abstract class SerializableDataFile {
abstract Builder setPartitionPath(String partitionPath);
+ abstract Builder setJsonPartition(String jsonPartition);
+
abstract Builder setPartitionSpecId(int partitionSpec);
abstract Builder setKeyMetadata(ByteBuffer keyMetadata);
@@ -149,16 +161,38 @@ public abstract class SerializableDataFile {
abstract SerializableDataFile build();
}
- public static SerializableDataFile from(DataFile f, String partitionPath) {
- return from(f, partitionPath, true);
+ public static SerializableDataFile from(DataFile f, Map<Integer,
PartitionSpec> specs) {
+ return from(
+ f,
+ checkStateNotNull(
+ specs.get(f.specId()),
+ "Could not create a SerializableDataFile because DataFile is
written using a partition spec id '%s' that is not found in the provided specs:
%s",
+ f.specId(),
+ specs.keySet()),
+ true);
+ }
+
+ public static SerializableDataFile from(DataFile f, PartitionSpec spec) {
+ return from(f, spec, true);
}
/**
* Create a {@link SerializableDataFile} from a {@link DataFile} and its
associated {@link
* PartitionKey}.
*/
- public static SerializableDataFile from(
- DataFile f, String partitionPath, boolean includeMetrics) {
+ public static SerializableDataFile from(DataFile f, PartitionSpec spec,
boolean includeMetrics) {
+ if (spec.specId() != f.specId()) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Cannot serialize DataFile: its partition spec id %s does not
match the provided "
+ + "spec id %s. Serialize the file with the exact spec it was
written with.",
+ f.specId(), spec.specId()));
+ }
+ // jsonPartition is the primary (handles evolved specs, special
characters).
+ // partitionPath is the fallback for values that don't round-trip through
JSON.
+ String jsonPartition = SingleValueParser.toJson(spec.partitionType(),
f.partition());
+ String partitionPath = spec.partitionToPath(f.partition());
+
SerializableDataFile.Builder builder =
SerializableDataFile.builder()
.setPath(f.location())
@@ -166,6 +200,7 @@ public abstract class SerializableDataFile {
.setRecordCount(f.recordCount())
.setFileSizeInBytes(f.fileSizeInBytes())
.setPartitionPath(partitionPath)
+ .setJsonPartition(jsonPartition)
.setPartitionSpecId(f.specId())
.setKeyMetadata(f.keyMetadata())
.setSplitOffsets(f.splitOffsets())
@@ -211,16 +246,36 @@ public abstract class SerializableDataFile {
toByteBufferMap(getLowerBounds()),
toByteBufferMap(getUpperBounds()));
- return DataFiles.builder(partitionSpec)
- .withFormat(FileFormat.fromString(getFileFormat()))
- .withPath(getPath())
- .withPartitionPath(getPartitionPath())
- .withEncryptionKeyMetadata(getKeyMetadata())
- .withFileSizeInBytes(getFileSizeInBytes())
- .withMetrics(dataFileMetrics)
- .withSplitOffsets(getSplitOffsets())
- .withFirstRowId(getFirstRowId())
- .build();
+ DataFiles.Builder builder =
+ DataFiles.builder(partitionSpec)
+ .withFormat(FileFormat.fromString(getFileFormat()))
+ .withPath(getPath())
+ .withEncryptionKeyMetadata(getKeyMetadata())
+ .withFileSizeInBytes(getFileSizeInBytes())
+ .withMetrics(dataFileMetrics)
+ .withSplitOffsets(getSplitOffsets())
+ .withFirstRowId(getFirstRowId());
+
+ @Nullable String jsonPartition = getJsonPartition();
+ if (jsonPartition != null) {
+ try {
+ builder = builder.withPartition(partition(partitionSpec));
+ } catch (RuntimeException e) {
+ // Some partition values (e.g. NaN / Infinity floating-point) don't
round-trip through the
+ // JSON representation; fall back to the partition-path string, which
handles them.
+ builder = builder.withPartitionPath(getPartitionPath());
+ }
+ } else {
+ // Elements decoded from a pre-jsonPartition release carry only the
partition path.
+ builder = builder.withPartitionPath(getPartitionPath());
+ }
+ return builder.build();
+ }
+
+ @VisibleForTesting
+ StructLike partition(PartitionSpec spec) {
+ return (StructLike)
+ SingleValueParser.fromJson(spec.partitionType(),
checkStateNotNull(getJsonPartition()));
}
// ByteBuddyUtils has trouble converting Map value type ByteBuffer
@@ -275,6 +330,8 @@ public abstract class SerializableDataFile {
&& getFileSizeInBytes() == that.getFileSizeInBytes()
&& getPartitionPath().equals(that.getPartitionPath())
&& getPartitionSpecId() == that.getPartitionSpecId()
+ && Objects.equals(getPartitionPath(), that.getPartitionPath())
+ && Objects.equals(getJsonPartition(), that.getJsonPartition())
&& Objects.equals(getKeyMetadata(), that.getKeyMetadata())
&& Objects.equals(getSplitOffsets(), that.getSplitOffsets())
&& Objects.equals(getColumnSizes(), that.getColumnSizes())
@@ -320,6 +377,7 @@ public abstract class SerializableDataFile {
getRecordCount(),
getFileSizeInBytes(),
getPartitionPath(),
+ getJsonPartition(),
getPartitionSpecId(),
getKeyMetadata(),
getSplitOffsets(),
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/WritePartitionedRowsToFiles.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/WritePartitionedRowsToFiles.java
index d1a08980fa9..338a2162080 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/WritePartitionedRowsToFiles.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/WritePartitionedRowsToFiles.java
@@ -152,7 +152,8 @@ class WritePartitionedRowsToFiles
writer.close();
}
- SerializableDataFile sdf =
SerializableDataFile.from(writer.getDataFile(), partitionPath);
+ // Serialize against the file's own spec
+ SerializableDataFile sdf =
SerializableDataFile.from(writer.getDataFile(), table.specs());
out.output(
FileWriteResult.builder()
.setTableIdentifier(destination.getTableIdentifier())
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtils.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtils.java
index 34f26eb9cdf..b8c18d1a4e5 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtils.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtils.java
@@ -135,7 +135,7 @@ public final class CdcReadUtils {
outputSchema,
checkStateNotNull(table.specs().get(task.getSpecId())),
task.getDataFile().createDataFile(table.specs()),
- task.getDataFile().getFileSequenceNumber(),
+ task.getDataFile().getDataSequenceNumber(),
start,
length,
combined);
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/SerializableChangelogTask.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/SerializableChangelogTask.java
index 9b6955d9e4a..3410c0a9d7e 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/SerializableChangelogTask.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/SerializableChangelogTask.java
@@ -118,8 +118,8 @@ public abstract class SerializableChangelogTask {
abstract Builder setDataFile(SerializableDataFile dataFile);
@SchemaIgnore
- public Builder setDataFile(DataFile df, String partitionPath, boolean
includeMetrics) {
- return setDataFile(SerializableDataFile.from(df, partitionPath,
includeMetrics));
+ public Builder setDataFile(DataFile df, PartitionSpec spec, boolean
includeMetrics) {
+ return setDataFile(SerializableDataFile.from(df, spec, includeMetrics));
}
abstract Builder setExistingDeletes(List<SerializableDeleteFile>
existingDeletes);
@@ -159,10 +159,7 @@ public abstract class SerializableChangelogTask {
.setOperation(task.operation())
.setOrdinal(task.changeOrdinal())
.setCommitSnapshotId(task.commitSnapshotId())
- .setDataFile(
- contentScanTask.file(),
- spec.partitionToPath(contentScanTask.partition()),
- includeMetrics)
+ .setDataFile(contentScanTask.file(), spec, includeMetrics)
.setSpecId(spec.specId())
.setStart(contentScanTask.start())
.setLength(contentScanTask.length())
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/PartitionUtilsTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/PartitionUtilsTest.java
index 740ede55811..3aa6a5e9464 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/PartitionUtilsTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/PartitionUtilsTest.java
@@ -190,7 +190,7 @@ public class PartitionUtilsTest {
.withRecordCount(2L)
.withFirstRowId(99L)
.build();
- setFileSequenceNumber(file, 42L);
+ setDataSequenceNumber(file, 42L);
Map<Integer, ?> constants = PartitionUtils.constantsMap(spec, file, null);
@@ -202,7 +202,7 @@ public class PartitionUtilsTest {
}
@Test
- public void
testConstantsMapUsesExplicitSequenceNumberWhenFileSequenceIsUnavailable() {
+ public void
testConstantsMapUsesExplicitSequenceNumberWhenDataSequenceIsUnavailable() {
org.apache.iceberg.Schema icebergSchema =
new org.apache.iceberg.Schema(
Types.NestedField.required(1, "id", Types.IntegerType.get()),
@@ -223,12 +223,12 @@ public class PartitionUtilsTest {
assertEquals("B", constants.get(2));
}
- private static void setFileSequenceNumber(DataFile dataFile, long
fileSequenceNumber)
+ private static void setDataSequenceNumber(DataFile dataFile, long
dataSequenceNumber)
throws Exception {
- Method method = dataFile.getClass().getMethod("setFileSequenceNumber",
Long.class);
+ Method method = dataFile.getClass().getMethod("setDataSequenceNumber",
Long.class);
method.setAccessible(true);
try {
- method.invoke(dataFile, fileSequenceNumber);
+ method.invoke(dataFile, dataSequenceNumber);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/RecordWriterManagerTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/RecordWriterManagerTest.java
index 821fb2ac7b2..03b3560f746 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/RecordWriterManagerTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/RecordWriterManagerTest.java
@@ -42,7 +42,6 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -433,15 +432,8 @@ public class RecordWriterManagerTest {
DataFile datafile = writer.getDataFile();
assertEquals(2L, datafile.recordCount());
- Map<String, PartitionField> partitionFieldMap = new HashMap<>();
- for (PartitionField partitionField : PARTITION_SPEC.fields()) {
- partitionFieldMap.put(partitionField.name(), partitionField);
- }
-
- String partitionPath =
- RecordWriterManager.getPartitionDataPath(partitionKey.toPath(),
partitionFieldMap);
DataFile roundTripDataFile =
- SerializableDataFile.from(datafile, partitionPath)
+ SerializableDataFile.from(datafile, PARTITION_SPEC)
.createDataFile(ImmutableMap.of(PARTITION_SPEC.specId(),
PARTITION_SPEC));
checkDataFileEquality(datafile, roundTripDataFile);
@@ -477,14 +469,8 @@ public class RecordWriterManagerTest {
writer.close();
// fetch data file and its serializable version
- Map<String, PartitionField> partitionFieldMap = new HashMap<>();
- for (PartitionField partitionField : PARTITION_SPEC.fields()) {
- partitionFieldMap.put(partitionField.name(), partitionField);
- }
- String partitionPath =
- RecordWriterManager.getPartitionDataPath(partitionKey.toPath(),
partitionFieldMap);
DataFile datafile = writer.getDataFile();
- SerializableDataFile serializableDataFile =
SerializableDataFile.from(datafile, partitionPath);
+ SerializableDataFile serializableDataFile =
SerializableDataFile.from(datafile, PARTITION_SPEC);
assertEquals(2L, datafile.recordCount());
assertEquals(serializableDataFile.getPartitionSpecId(), datafile.specId());
@@ -645,7 +631,7 @@ public class RecordWriterManagerTest {
expectedPartitions.add(name + "=" + URLEncoder.encode(val,
UTF_8.toString()));
}
String expectedPartitionPath = String.join("/", expectedPartitions);
- assertEquals(expectedPartitionPath, dataFile.getPartitionPath());
+ assertEquals(expectedPartitionPath,
spec.partitionToPath(dataFile.partition(spec)));
assertThat(dataFile.getPath(), containsString(expectedPartitionPath));
}
@@ -698,9 +684,10 @@ public class RecordWriterManagerTest {
assertEquals(1, files.size());
SerializableDataFile dataFile = files.get(0);
assertEquals(1, dataFile.getRecordCount());
+ String partitionPath = spec.partitionToPath(dataFile.partition(spec));
for (Schema.Field field : bucketSchema.getFields()) {
String expectedPartition = field.getName() + "_bucket";
- assertThat(dataFile.getPartitionPath(),
containsString(expectedPartition));
+ assertThat(partitionPath, containsString(expectedPartition));
assertThat(dataFile.getPath(), containsString(expectedPartition));
}
}
@@ -792,6 +779,7 @@ public class RecordWriterManagerTest {
serializableDataFile.createDataFile(
catalogConfig.catalog().loadTable(dest.getValue().getTableIdentifier()).specs());
assertThat(dataFile.path().toString(), containsString(expectedPartition));
+ assertEquals(expectedPartition,
spec.partitionToPath(dataFile.partition()));
}
@Rule public ExpectedException thrown = ExpectedException.none();
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SerializableDataFileTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SerializableDataFileTest.java
index 5126822c06f..e02cfaff084 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SerializableDataFileTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SerializableDataFileTest.java
@@ -19,6 +19,9 @@ package org.apache.beam.sdk.io.iceberg;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
@@ -30,12 +33,16 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
+import org.apache.beam.sdk.schemas.SchemaRegistry;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
import org.apache.iceberg.DataFile;
import org.apache.iceberg.DataFiles;
import org.apache.iceberg.FileFormat;
import org.apache.iceberg.Metrics;
import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.data.GenericRecord;
import org.apache.iceberg.types.Conversions;
import org.apache.iceberg.types.Types;
import org.junit.Test;
@@ -52,6 +59,7 @@ public class SerializableDataFileTest {
.add("recordCount")
.add("fileSizeInBytes")
.add("partitionPath")
+ .add("jsonPartition")
.add("partitionSpecId")
.add("keyMetadata")
.add("splitOffsets")
@@ -91,6 +99,36 @@ public class SerializableDataFileTest {
}
}
+ /**
+ * B13/A6: every field is pinned with {@code @SchemaFieldNumber} because
Dataflow's in-place
+ * {@code --update} rejects a reordered schema or a changed field
nullability. Lock the field
+ * count, order/names, and the two nullability-sensitive fields so any
future edit trips review.
+ */
+ @Test
+ public void schemaFieldNumbersArePinned() throws Exception {
+ org.apache.beam.sdk.schemas.Schema schema =
+ SchemaRegistry.createDefault().getSchema(SerializableDataFile.class);
+ assertEquals(18, schema.getFieldCount());
+ assertEquals("path", schema.getField(0).getName());
+ assertEquals("fileFormat", schema.getField(1).getName());
+ assertEquals("recordCount", schema.getField(2).getName());
+ assertEquals("fileSizeInBytes", schema.getField(3).getName());
+ assertEquals("partitionPath", schema.getField(4).getName());
+ assertEquals("partitionSpecId", schema.getField(5).getName());
+ assertEquals("keyMetadata", schema.getField(6).getName());
+ assertEquals("splitOffsets", schema.getField(7).getName());
+ assertEquals("columnSizes", schema.getField(8).getName());
+ assertEquals("valueCounts", schema.getField(9).getName());
+ assertEquals("nullValueCounts", schema.getField(10).getName());
+ assertEquals("nanValueCounts", schema.getField(11).getName());
+ assertEquals("lowerBounds", schema.getField(12).getName());
+ assertEquals("upperBounds", schema.getField(13).getName());
+ assertEquals("dataSequenceNumber", schema.getField(14).getName());
+ assertEquals("fileSequenceNumber", schema.getField(15).getName());
+ assertEquals("firstRowId", schema.getField(16).getName());
+ assertEquals("jsonPartition", schema.getField(17).getName());
+ }
+
/**
* Bounds with {@code capacity > limit} must be copied by {@code [position,
limit)}, not by {@link
* ByteBuffer#array()}. Otherwise trailing 0x00 bytes leak into the manifest
bounds and break
@@ -126,7 +164,8 @@ public class SerializableDataFileTest {
.withMetrics(metrics)
.build();
- SerializableDataFile serialized = SerializableDataFile.from(dataFile, "");
+ SerializableDataFile serialized =
+ SerializableDataFile.from(dataFile, PartitionSpec.unpartitioned());
byte[] serializedLower = serialized.getLowerBounds().get(columnId);
byte[] serializedUpper = serialized.getUpperBounds().get(columnId);
@@ -141,4 +180,119 @@ public class SerializableDataFileTest {
assertArrayEquals(expectedLower, serializedLower);
assertArrayEquals(expectedUpper, serializedUpper);
}
+
+ /**
+ * F8: {@code from(DataFile, spec)} must populate BOTH the JSON partition
(primary) and the
+ * partition path (fallback), so the deprecated {@code partitionPath} schema
field stays non-null
+ * across releases (Dataflow's in-place pipeline update rejects a changed
field nullability).
+ */
+ @Test
+ public void fromPopulatesBothPartitionRepresentations() {
+ DataFile dataFile =
+ DataFiles.builder(PartitionSpec.unpartitioned())
+ .withFormat(FileFormat.PARQUET)
+ .withPath("gs://test-bucket/data/f.parquet")
+ .withFileSizeInBytes(1L)
+ .withRecordCount(1L)
+ .build();
+
+ SerializableDataFile sdf = SerializableDataFile.from(dataFile,
PartitionSpec.unpartitioned());
+
+ assertEquals(
+ "partition path must be populated (unpartitioned -> empty string), not
null",
+ "",
+ sdf.getPartitionPath());
+ assertNotNull("json partition must also be populated",
sdf.getJsonPartition());
+ }
+
+ /**
+ * F6: a {@link DataFile} must be serialized with the EXACT spec it was
written with — a
+ * mismatched spec id (usually a spec evolution on a shared/refreshed table
between writing and
+ * serializing) must fail loudly rather than silently encode the partition
under the wrong field
+ * ids.
+ */
+ @Test
+ public void fromRejectsSpecIdMismatch() {
+ DataFile unpartitioned =
+ DataFiles.builder(PartitionSpec.unpartitioned())
+ .withFormat(FileFormat.PARQUET)
+ .withPath("gs://test-bucket/data/f.parquet")
+ .withFileSizeInBytes(1L)
+ .withRecordCount(1L)
+ .build();
+ Schema schema =
+ new Schema(
+ Types.NestedField.required(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "data", Types.StringType.get()));
+ PartitionSpec otherSpec =
PartitionSpec.builderFor(schema).identity("id").withSpecId(1).build();
+
+ IllegalArgumentException ex =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> SerializableDataFile.from(unpartitioned, otherSpec));
+ assertTrue(
+ "message should explain the spec-id mismatch: " + ex.getMessage(),
+ ex.getMessage().contains("does not match"));
+ }
+
+ /**
+ * B13/F8: elements encoded by a PRE-jsonPartition pipeline carry only
{@code partitionPath}
+ * (field 4), with {@code jsonPartition} (field 14) null. {@code
createDataFile} must reconstruct
+ * the partition via {@code withPartitionPath} rather than crash on the
missing JSON.
+ */
+ @Test
+ public void legacyElementWithoutJsonPartitionReconstructsViaPartitionPath() {
+ Schema schema =
+ new Schema(
+ Types.NestedField.required(1, "id", Types.LongType.get()),
+ Types.NestedField.required(2, "shard", Types.IntegerType.get()));
+ PartitionSpec spec =
PartitionSpec.builderFor(schema).identity("shard").build();
+
+ SerializableDataFile legacy =
+ SerializableDataFile.builder()
+ .setPath("gs://test-bucket/data/legacy.parquet")
+ .setFileFormat("PARQUET")
+ .setRecordCount(1L)
+ .setFileSizeInBytes(1L)
+ .setPartitionPath("shard=5")
+ .setPartitionSpecId(spec.specId())
+ .build(); // no setJsonPartition -> jsonPartition is null
(pre-upgrade encoding)
+
+ DataFile reconstructed =
legacy.createDataFile(ImmutableMap.of(spec.specId(), spec));
+ assertEquals("shard=5", spec.partitionToPath(reconstructed.partition()));
+ }
+
+ /**
+ * F7: NaN / Infinity floating-point partition values don't round-trip
through the JSON partition
+ * representation ({@code SingleValueParser.fromJson} rejects the quoted
{@code "NaN"}).
+ * Reconstruct must fall back to the partition-path string, which handles
them, rather than
+ * crash-looping the sink at commit time.
+ */
+ @Test
+ public void nanFloatPartitionReconstructsViaPathFallback() {
+ Schema schema =
+ new Schema(
+ Types.NestedField.required(1, "f", Types.FloatType.get()),
+ Types.NestedField.optional(2, "data", Types.StringType.get()));
+ PartitionSpec spec =
PartitionSpec.builderFor(schema).identity("f").build();
+ GenericRecord partition = GenericRecord.create(spec.partitionType());
+ partition.setField("f", Float.NaN);
+ DataFile dataFile =
+ DataFiles.builder(spec)
+ .withFormat(FileFormat.PARQUET)
+ .withPath("gs://test-bucket/data/nan.parquet")
+ .withFileSizeInBytes(1L)
+ .withRecordCount(1L)
+ .withPartition(partition)
+ .build();
+
+ SerializableDataFile sdf = SerializableDataFile.from(dataFile, spec);
+ // Must reconstruct without throwing (JSON decode of NaN fails ->
partition-path fallback).
+ DataFile reconstructed = sdf.createDataFile(ImmutableMap.of(spec.specId(),
spec));
+
+ Object value = reconstructed.partition().get(0, Object.class);
+ assertTrue(
+ "partition value must round-trip as NaN",
+ value instanceof Float && Float.isNaN((Float) value));
+ }
}
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtilsTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtilsTest.java
index 546386073ff..75bfaa1775d 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtilsTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/CdcReadUtilsTest.java
@@ -341,7 +341,7 @@ public class CdcReadUtilsTest {
Table table) {
return SerializableChangelogTask.builder()
.setType(type)
- .setDataFile(dataFile,
table.spec().partitionToPath(dataFile.partition()), true)
+ .setDataFile(dataFile, table.spec(), true)
.setAddedDeletes(serializableDeletes(addedDeletes, table))
.setExistingDeletes(serializableDeletes(existingDeletes, table))
.setSpecId(table.spec().specId())
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScannerTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScannerTest.java
index 1e4b6ba5802..afb53627894 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScannerTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ChangelogScannerTest.java
@@ -338,7 +338,7 @@ public class ChangelogScannerTest {
.build();
return SerializableChangelogTask.builder()
.setType(SerializableChangelogTask.Type.ADDED_ROWS)
- .setDataFile(SerializableDataFile.from(file, "", false))
+ .setDataFile(SerializableDataFile.from(file,
PartitionSpec.unpartitioned(), false))
.setSpecId(UNPARTITIONED_SPEC.specId())
.setOperation(ChangelogOperation.INSERT)
.setOrdinal(0)
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/LocalResolveDoFnTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/LocalResolveDoFnTest.java
index 7e287035319..2dfbb854b37 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/LocalResolveDoFnTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/LocalResolveDoFnTest.java
@@ -306,7 +306,7 @@ public class LocalResolveDoFnTest {
SerializableChangelogTask.Type type, DataFile dataFile, Table table,
long snapshotId) {
return SerializableChangelogTask.builder()
.setType(type)
- .setDataFile(dataFile,
table.spec().partitionToPath(dataFile.partition()), true)
+ .setDataFile(dataFile, table.spec(), true)
.setAddedDeletes(ImmutableList.of())
.setExistingDeletes(ImmutableList.of())
.setSpecId(table.spec().specId())
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ReadFromChangelogsTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ReadFromChangelogsTest.java
index 69591e6eaa7..7a1e71d3d80 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ReadFromChangelogsTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/ReadFromChangelogsTest.java
@@ -297,7 +297,7 @@ public class ReadFromChangelogsTest {
long snapshotId) {
return SerializableChangelogTask.builder()
.setType(type)
- .setDataFile(dataFile,
table.spec().partitionToPath(dataFile.partition()), true)
+ .setDataFile(dataFile, table.spec(), true)
.setAddedDeletes(serializableDeletes(addedDeletes, table))
.setExistingDeletes(serializableDeletes(existingDeletes, table))
.setSpecId(table.spec().specId())
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/SerializableChangelogTaskTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/SerializableChangelogTaskTest.java
index aa77d37af7a..32d0fc691c0 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/SerializableChangelogTaskTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/SerializableChangelogTaskTest.java
@@ -81,7 +81,7 @@ public class SerializableChangelogTaskTest {
SerializableChangelogTask task =
SerializableChangelogTask.builder()
.setType(SerializableChangelogTask.Type.ADDED_ROWS)
- .setDataFile(SerializableDataFile.from(DATA_FILE, "", false))
+ .setDataFile(SerializableDataFile.from(DATA_FILE,
PartitionSpec.unpartitioned(), false))
.setSpecId(SPEC.specId())
.setOperation(ChangelogOperation.INSERT)
.setOrdinal(7)