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 9c561e2983e [Iceberg] Make timestamptz return new Timestamp.MICROS
logical type (#39344)
9c561e2983e is described below
commit 9c561e2983e6b1bb912d1a9127da2474a07178e0
Author: Ahmed Abualsaud <[email protected]>
AuthorDate: Thu Jul 30 19:49:46 2026 -0700
[Iceberg] Make timestamptz return new Timestamp.MICROS logical type (#39344)
* switch to new timestamp logical type
* changes and trigger ITs
* address comments
* format changes
* add link
* tighten BQ conversion logic
* spotless
* negative test; cleanup
* nuance timestamp conversion; use if-block
* nuance timestamp conversion; use if-block
* add Timestamp to test and trigger ITs
---------
Co-authored-by: Ahmed Abualsaud <[email protected]>
Co-authored-by: Ahmed Abualsaud <[email protected]>
---
.../IO_Iceberg_Integration_Tests.json | 2 +-
.../beam_PostCommit_Python_Xlang_IO_Direct.json | 2 +-
CHANGES.md | 4 ++
.../beam/sdk/io/gcp/bigquery/BigQueryUtils.java | 6 ++
.../sdk/io/gcp/bigquery/BigQueryUtilsTest.java | 42 +++++++++---
.../org/apache/beam/sdk/io/iceberg/IcebergIO.java | 14 +++-
.../beam/sdk/io/iceberg/IcebergScanConfig.java | 9 ++-
.../apache/beam/sdk/io/iceberg/IcebergUtils.java | 76 +++++++++++++++++-----
.../beam/sdk/io/iceberg/IncrementalScanSource.java | 4 +-
.../apache/beam/sdk/io/iceberg/ReadFromTasks.java | 4 +-
.../org/apache/beam/sdk/io/iceberg/ScanSource.java | 4 +-
.../apache/beam/sdk/io/iceberg/ScanTaskReader.java | 5 +-
.../beam/sdk/io/iceberg/IcebergIOReadTest.java | 48 ++++++++++++++
.../beam/sdk/io/iceberg/IcebergUtilsTest.java | 43 +++++++++++-
.../IcebergWriteSchemaTransformProviderTest.java | 13 +++-
.../catalog/BigQueryMetastoreCatalogIT.java | 1 +
.../io/iceberg/catalog/IcebergCatalogBaseIT.java | 11 ++--
.../transforms/managed_iceberg_it_test.py | 4 +-
18 files changed, 248 insertions(+), 44 deletions(-)
diff --git a/.github/trigger_files/IO_Iceberg_Integration_Tests.json
b/.github/trigger_files/IO_Iceberg_Integration_Tests.json
index 7ab7bcd9a9c..37dd25bf902 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": 2
+ "modification": 3
}
diff --git a/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Direct.json
b/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Direct.json
index e3d6056a5de..b2683333323 100644
--- a/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Direct.json
+++ b/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Direct.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/CHANGES.md b/CHANGES.md
index f71dab149d8..d853314a0ad 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -81,6 +81,10 @@
## Breaking Changes
* (Python) Removed `google-perftools` from the SDK container images. Users who
wish to use `--profiler_agent=tcmalloc` should install google-perftools APT
package in their custom container images separately
([#39323](https://github.com/apache/beam/issues/39323)).
+* [IcebergIO] Reading a `timestamptz` column will now return a
`Timestamp.MICROS` Beam logical type to preserve
+ microseconds (the old Beam `Schema.FieldType#DATETIME` primitive type
truncates past milliseconds). This may break
+ existing streaming read pipelines. It also breaks Python reads when a
`timestamptz` column is present. Use pipeline
+ option `--updateCompatibilityVersion=2.75.0` (or any older version) to keep
the old behavior ([#39344](https://github.com/apache/beam/issues/39344)).
* `DoFn.process` returning a `str`, `bytes`, or `dict` (instead of an iterable
wrapping one) now raises a `TypeError` rather than silently iterating
per-character/byte/key (Python)
([#18712](https://github.com/apache/beam/issues/18712)).
* (Java) Added `DRAINING` and `DRAINED` states to `PipelineResult`, including
runner state mappings and Dataflow update handling
([#39020](https://github.com/apache/beam/issues/39020)).
* (Python) Typehints of dataclass fields are honored during type inferences.
To restore the behavior of fallback-to-any,
diff --git
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryUtils.java
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryUtils.java
index d9805a6f4e0..5ba2d17c127 100644
---
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryUtils.java
+++
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryUtils.java
@@ -932,6 +932,12 @@ public class BigQueryUtils {
return java.time.Instant.parse(jsonBQString);
}
} else if (fieldType.isLogicalType(Timestamp.IDENTIFIER)) {
+ if (!jsonBQString.contains("UTC")) {
+ BigDecimal bd = new BigDecimal(jsonBQString);
+ long seconds = bd.longValue();
+ long nanos =
bd.subtract(BigDecimal.valueOf(seconds)).movePointRight(9).longValue();
+ return java.time.Instant.ofEpochSecond(seconds, nanos);
+ }
return VAR_PRECISION_FORMATTER.parse(jsonBQString,
java.time.Instant::from);
}
}
diff --git
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryUtilsTest.java
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryUtilsTest.java
index b50e8448698..52dbef55286 100644
---
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryUtilsTest.java
+++
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryUtilsTest.java
@@ -41,6 +41,7 @@ import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
+import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
@@ -1444,22 +1445,43 @@ public class BigQueryUtilsTest {
@Test
@SuppressWarnings("JavaInstantGetSecondsGetNano")
- public void testToBeamRow_timestampMicros_utcSuffix() {
+ public void testToBeamRow_timestampMicros() {
Schema schema = Schema.builder().addLogicalTypeField("ts",
Timestamp.MICROS).build();
// BigQuery format with " UTC" suffix
String timestamp = "2024-08-10 16:52:07.123456 UTC";
+ String parsableTimestamp = "2024-08-10T16:52:07.123456Z";
+ String negativeTimestamp = "1960-08-10T16:52:07.000123Z";
- Row beamRow = BigQueryUtils.toBeamRow(schema, new TableRow().set("ts",
timestamp));
+ java.time.Instant instant =
OffsetDateTime.parse(parsableTimestamp).toInstant();
+ String value = instant.getEpochSecond() + "." + instant.getNano() / 1000;
+ java.time.Instant negInstant =
OffsetDateTime.parse(negativeTimestamp).toInstant();
+ String negValue =
+ BigDecimal.valueOf(negInstant.getEpochSecond())
+ .add(BigDecimal.valueOf(negInstant.getNano(), 9))
+ .toPlainString();
- java.time.Instant actual = (java.time.Instant) beamRow.getValue("ts");
- assertEquals(2024, actual.atZone(java.time.ZoneOffset.UTC).getYear());
- assertEquals(8, actual.atZone(java.time.ZoneOffset.UTC).getMonthValue());
- assertEquals(10, actual.atZone(java.time.ZoneOffset.UTC).getDayOfMonth());
- assertEquals(16, actual.atZone(java.time.ZoneOffset.UTC).getHour());
- assertEquals(52, actual.atZone(java.time.ZoneOffset.UTC).getMinute());
- assertEquals(7, actual.atZone(java.time.ZoneOffset.UTC).getSecond());
- assertEquals(123456000, actual.getNano());
+ List<TableRow> testRows =
+ Arrays.asList(
+ new TableRow().set("ts", timestamp),
+ new TableRow().set("ts", value),
+ new TableRow().set("negative", true).set("ts", negValue));
+
+ for (TableRow row : testRows) {
+ Row beamRow = BigQueryUtils.toBeamRow(schema, row);
+
+ java.time.Instant actual = (java.time.Instant) beamRow.getValue("ts");
+
+ assertEquals(
+ row.get("negative") == null ? 2024 : 1960,
+ actual.atZone(java.time.ZoneOffset.UTC).getYear());
+ assertEquals(8, actual.atZone(java.time.ZoneOffset.UTC).getMonthValue());
+ assertEquals(10,
actual.atZone(java.time.ZoneOffset.UTC).getDayOfMonth());
+ assertEquals(16, actual.atZone(java.time.ZoneOffset.UTC).getHour());
+ assertEquals(52, actual.atZone(java.time.ZoneOffset.UTC).getMinute());
+ assertEquals(7, actual.atZone(java.time.ZoneOffset.UTC).getSecond());
+ assertEquals(row.get("negative") == null ? 123456000 : 123000,
actual.getNano());
+ }
}
@Test
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergIO.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergIO.java
index abdc2a179b5..ee5755898b7 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergIO.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergIO.java
@@ -25,6 +25,7 @@ import java.util.List;
import java.util.Map;
import org.apache.beam.sdk.annotations.Internal;
import org.apache.beam.sdk.io.Read;
+import org.apache.beam.sdk.options.StreamingOptions;
import org.apache.beam.sdk.schemas.Schema;
import org.apache.beam.sdk.transforms.PTransform;
import org.apache.beam.sdk.values.PBegin;
@@ -699,12 +700,23 @@ public class IcebergIO {
Table table = TableCache.get(getCatalogConfig(), tableId);
+ @Nullable
+ String updateCompatibilityVersion =
+ input
+ .getPipeline()
+ .getOptions()
+ .as(StreamingOptions.class)
+ .getUpdateCompatibilityVersion();
+
IcebergScanConfig scanConfig =
IcebergScanConfig.builder()
.setCatalogConfig(getCatalogConfig())
.setScanType(IcebergScanConfig.ScanType.TABLE)
.setTableIdentifier(tableId)
-
.setSchema(IcebergUtils.icebergSchemaToBeamSchema(table.schema()))
+ .setSchema(
+ IcebergUtils.icebergSchemaToBeamSchema(
+ table.schema(), updateCompatibilityVersion))
+ .setUpdateCompatibilityVersion(updateCompatibilityVersion)
.setFromSnapshotInclusive(getFromSnapshot())
.setToSnapshot(getToSnapshot())
.setFromTimestamp(getFromTimestamp())
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
index 95ea6cf1bd4..d184a84edf9 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
@@ -164,7 +164,8 @@ public abstract class IcebergScanConfig implements
Serializable {
public Schema rowIdBeamSchema() {
if (cachedRowIdBeamSchema == null) {
- cachedRowIdBeamSchema = icebergSchemaToBeamSchema(recordIdSchema());
+ cachedRowIdBeamSchema =
+ icebergSchemaToBeamSchema(recordIdSchema(),
getUpdateCompatibilityVersion());
}
return cachedRowIdBeamSchema;
}
@@ -237,6 +238,9 @@ public abstract class IcebergScanConfig implements
Serializable {
@Pure
public abstract boolean getUseCdc();
+ @Pure
+ public abstract @Nullable String getUpdateCompatibilityVersion();
+
@Pure
public abstract @Nullable Boolean getStreaming();
@@ -335,6 +339,9 @@ public abstract class IcebergScanConfig implements
Serializable {
public abstract Builder setUseCdc(boolean useCdc);
+ public abstract Builder setUpdateCompatibilityVersion(
+ @Nullable String updateCompatibilityVersion);
+
public abstract Builder setStreaming(@Nullable Boolean streaming);
public abstract Builder setPollInterval(@Nullable Duration pollInterval);
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergUtils.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergUtils.java
index 309205707a9..35accf45976 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergUtils.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergUtils.java
@@ -40,7 +40,9 @@ import
org.apache.beam.sdk.schemas.logicaltypes.FixedPrecisionNumeric;
import org.apache.beam.sdk.schemas.logicaltypes.MicrosInstant;
import org.apache.beam.sdk.schemas.logicaltypes.PassThroughLogicalType;
import org.apache.beam.sdk.schemas.logicaltypes.SqlTypes;
+import org.apache.beam.sdk.schemas.logicaltypes.Timestamp;
import org.apache.beam.sdk.util.Preconditions;
+import org.apache.beam.sdk.util.construction.TransformUpgrader;
import org.apache.beam.sdk.values.PCollection;
import org.apache.beam.sdk.values.Row;
import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
@@ -83,7 +85,8 @@ public class IcebergUtils {
.put(MicrosInstant.IDENTIFIER, Types.TimestampType.withZone())
.build();
- private static Schema.FieldType icebergTypeToBeamFieldType(final Type type) {
+ private static Schema.FieldType icebergTypeToBeamFieldType(
+ final Type type, @Nullable String updateCompatibilityVersion) {
switch (type.typeId()) {
case BOOLEAN:
return Schema.FieldType.BOOLEAN;
@@ -102,7 +105,14 @@ public class IcebergUtils {
case TIMESTAMP:
Types.TimestampType ts = (Types.TimestampType) type.asPrimitiveType();
if (ts.shouldAdjustToUTC()) {
- return Schema.FieldType.DATETIME;
+ // timestamptz. The micros-precision Timestamp logical type
preserves microseconds, while
+ // the legacy DATETIME (joda) mapping truncates to millis. Gated for
update compatibility.
+ if (updateCompatibilityVersion != null
+ && !updateCompatibilityVersion.isEmpty()
+ && TransformUpgrader.compareVersions(updateCompatibilityVersion,
"2.76.0") < 0) {
+ return Schema.FieldType.DATETIME;
+ }
+ return Schema.FieldType.logicalType(Timestamp.MICROS);
}
return Schema.FieldType.logicalType(SqlTypes.DATETIME);
case STRING:
@@ -114,36 +124,51 @@ public class IcebergUtils {
case DECIMAL:
return Schema.FieldType.DECIMAL;
case STRUCT:
- return
Schema.FieldType.row(icebergStructTypeToBeamSchema(type.asStructType()));
+ return Schema.FieldType.row(
+ icebergStructTypeToBeamSchema(type.asStructType(),
updateCompatibilityVersion));
case LIST:
- return
Schema.FieldType.array(icebergTypeToBeamFieldType(type.asListType().elementType()));
+ return Schema.FieldType.array(
+ icebergTypeToBeamFieldType(
+ type.asListType().elementType(), updateCompatibilityVersion));
case MAP:
return Schema.FieldType.map(
- icebergTypeToBeamFieldType(type.asMapType().keyType()),
- icebergTypeToBeamFieldType(type.asMapType().valueType()));
+ icebergTypeToBeamFieldType(type.asMapType().keyType(),
updateCompatibilityVersion),
+ icebergTypeToBeamFieldType(type.asMapType().valueType(),
updateCompatibilityVersion));
default:
throw new RuntimeException("Unrecognized Iceberg Type: " +
type.typeId());
}
}
- private static Schema.Field icebergFieldToBeamField(final Types.NestedField
field) {
- return Schema.Field.of(field.name(),
icebergTypeToBeamFieldType(field.type()))
+ private static Schema.Field icebergFieldToBeamField(
+ final Types.NestedField field, @Nullable String
updateCompatibilityVersion) {
+ return Schema.Field.of(
+ field.name(), icebergTypeToBeamFieldType(field.type(),
updateCompatibilityVersion))
.withNullable(field.isOptional());
}
/** Converts an Iceberg {@link org.apache.iceberg.Schema} to a Beam {@link
Schema}. */
public static Schema icebergSchemaToBeamSchema(final
org.apache.iceberg.Schema schema) {
+ return icebergSchemaToBeamSchema(schema, null);
+ }
+
+ /**
+ * Converts an Iceberg {@link org.apache.iceberg.Schema} to a Beam {@link
Schema}, accounting for
+ * update compatibility.
+ */
+ public static Schema icebergSchemaToBeamSchema(
+ final org.apache.iceberg.Schema schema, @Nullable String
updateCompatibilityVersion) {
Schema.Builder builder = Schema.builder();
for (Types.NestedField f : schema.columns()) {
- builder.addField(icebergFieldToBeamField(f));
+ builder.addField(icebergFieldToBeamField(f, updateCompatibilityVersion));
}
return builder.build();
}
- private static Schema icebergStructTypeToBeamSchema(final Types.StructType
struct) {
+ private static Schema icebergStructTypeToBeamSchema(
+ final Types.StructType struct, @Nullable String
updateCompatibilityVersion) {
Schema.Builder builder = Schema.builder();
for (Types.NestedField f : struct.fields()) {
- builder.addField(icebergFieldToBeamField(f));
+ builder.addField(icebergFieldToBeamField(f, updateCompatibilityVersion));
}
return builder.build();
}
@@ -198,7 +223,17 @@ public class IcebergUtils {
String logicalTypeIdentifier = logicalType.getIdentifier();
@Nullable Type type =
BEAM_LOGICAL_TYPES_TO_ICEBERG_TYPES.get(logicalTypeIdentifier);
if (type == null) {
- throw new RuntimeException("Unsupported Beam logical type " +
logicalTypeIdentifier);
+ if (beamType.isLogicalType(Timestamp.IDENTIFIER)) {
+ int precision = checkStateNotNull(logicalType.getArgument());
+ if (precision == Timestamp.MICROS.getArgument()) {
+ type = Types.TimestampType.withZone();
+ } else {
+ throw new UnsupportedOperationException(
+ "Unsupported Timestamp precision: " + precision);
+ }
+ } else {
+ throw new RuntimeException("Unsupported Beam logical type " +
logicalTypeIdentifier);
+ }
}
return new TypeAndMaxId(--nestedFieldId, type);
} else if (beamType.getTypeName().isCollectionType()) { // ARRAY or
ITERABLE
@@ -613,21 +648,28 @@ public class IcebergUtils {
return LocalTime.parse(strValue);
} else if (type.isLogicalType(SqlTypes.DATETIME.getIdentifier())) {
return LocalDateTime.parse(strValue);
+ } else if (type.isLogicalType(Timestamp.IDENTIFIER)) {
+ return OffsetDateTime.parse(strValue).toInstant();
}
} else if (icebergValue instanceof Long) {
if (type.isLogicalType(SqlTypes.TIME.getIdentifier())) {
return DateTimeUtil.timeFromMicros((Long) icebergValue);
} else if (type.isLogicalType(SqlTypes.DATETIME.getIdentifier())) {
return DateTimeUtil.timestampFromMicros((Long) icebergValue);
+ } else if (type.isLogicalType(Timestamp.IDENTIFIER)) {
+ // timestamptz stored as micros since epoch -> java.time.Instant
(micros preserved).
+ return DateTimeUtil.timestamptzFromMicros((Long)
icebergValue).toInstant();
}
} else if (icebergValue instanceof Integer
&& type.isLogicalType(SqlTypes.DATE.getIdentifier())) {
return DateTimeUtil.dateFromDays((Integer) icebergValue);
- } else if (icebergValue instanceof OffsetDateTime
- && type.isLogicalType(SqlTypes.DATETIME.getIdentifier())) {
- return ((OffsetDateTime) icebergValue)
- .withOffsetSameInstant(ZoneOffset.UTC)
- .toLocalDateTime();
+ } else if (icebergValue instanceof OffsetDateTime) {
+ OffsetDateTime odt = (OffsetDateTime) icebergValue;
+ if (type.isLogicalType(SqlTypes.DATETIME.getIdentifier())) {
+ return odt.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime();
+ } else if (type.isLogicalType(Timestamp.IDENTIFIER)) {
+ return odt.toInstant();
+ }
}
// LocalDateTime, LocalDate, LocalTime
return icebergValue;
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IncrementalScanSource.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IncrementalScanSource.java
index 324eb817276..98870095e17 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IncrementalScanSource.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IncrementalScanSource.java
@@ -68,7 +68,9 @@ class IncrementalScanSource extends PTransform<PBegin,
PCollection<Row>> {
.setCoder(KvCoder.of(ReadTaskDescriptor.getCoder(),
ReadTask.getCoder()))
.apply(Redistribute.arbitrarily())
.apply("Read Rows From Tasks", ParDo.of(new ReadFromTasks(scanConfig)))
-
.setRowSchema(IcebergUtils.icebergSchemaToBeamSchema(scanConfig.getProjectedSchema()));
+ .setRowSchema(
+ IcebergUtils.icebergSchemaToBeamSchema(
+ scanConfig.getProjectedSchema(),
scanConfig.getUpdateCompatibilityVersion()));
}
/** Continuously watches for new snapshots. */
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFromTasks.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFromTasks.java
index 71114437731..438e2de464d 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFromTasks.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFromTasks.java
@@ -69,7 +69,9 @@ class ReadFromTasks extends DoFn<KV<ReadTaskDescriptor,
ReadTask>, Row> {
return;
}
FileScanTask task = fileScanTasks.get((int) l);
- Schema beamSchema =
IcebergUtils.icebergSchemaToBeamSchema(scanConfig.getProjectedSchema());
+ Schema beamSchema =
+ IcebergUtils.icebergSchemaToBeamSchema(
+ scanConfig.getProjectedSchema(),
scanConfig.getUpdateCompatibilityVersion());
try (CloseableIterable<Record> reader = ReadUtils.createReader(task,
table, scanConfig)) {
for (Record record : reader) {
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ScanSource.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ScanSource.java
index c407ef8d3e2..d8c1780c5db 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ScanSource.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ScanSource.java
@@ -116,7 +116,9 @@ class ScanSource extends BoundedSource<Row> {
@Override
public Coder<Row> getOutputCoder() {
- return
RowCoder.of(IcebergUtils.icebergSchemaToBeamSchema(scanConfig.getProjectedSchema()));
+ return RowCoder.of(
+ IcebergUtils.icebergSchemaToBeamSchema(
+ scanConfig.getProjectedSchema(),
scanConfig.getUpdateCompatibilityVersion()));
}
@Override
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ScanTaskReader.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ScanTaskReader.java
index c9ad372a075..c6ddd0a7e25 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ScanTaskReader.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ScanTaskReader.java
@@ -66,7 +66,10 @@ class ScanTaskReader extends
BoundedSource.BoundedReader<Row> {
public ScanTaskReader(ScanTaskSource source) {
this.source = source;
- this.beamSchema =
icebergSchemaToBeamSchema(source.getScanConfig().getProjectedSchema());
+ this.beamSchema =
+ icebergSchemaToBeamSchema(
+ source.getScanConfig().getProjectedSchema(),
+ source.getScanConfig().getUpdateCompatibilityVersion());
}
@Override
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergIOReadTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergIOReadTest.java
index edd26145816..7920fc37c84 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergIOReadTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergIOReadTest.java
@@ -32,7 +32,9 @@ import static org.junit.Assume.assumeTrue;
import java.io.File;
import java.io.IOException;
+import java.time.OffsetDateTime;
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -41,8 +43,11 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
+import org.apache.beam.sdk.Pipeline;
import org.apache.beam.sdk.io.iceberg.IcebergIO.ReadRows.StartingStrategy;
+import org.apache.beam.sdk.options.StreamingOptions;
import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.logicaltypes.Timestamp;
import org.apache.beam.sdk.testing.PAssert;
import org.apache.beam.sdk.testing.TestPipeline;
import org.apache.beam.sdk.transforms.DoFn;
@@ -83,6 +88,7 @@ import org.apache.iceberg.types.Types.StructType;
import org.apache.parquet.avro.AvroParquetWriter;
import org.apache.parquet.hadoop.ParquetWriter;
import org.checkerframework.checker.nullness.qual.Nullable;
+import org.joda.time.DateTime;
import org.joda.time.Duration;
import org.junit.ClassRule;
import org.junit.Rule;
@@ -731,6 +737,48 @@ public class IcebergIOReadTest {
runReadWithBoundary(false, false);
}
+ @Test
+ public void testTimestampUpdateCompat() throws IOException {
+ String val = "2026-07-15T13:18:20.053123+03:27";
+ OffsetDateTime ts = OffsetDateTime.parse(val);
+
+ TableIdentifier tableId =
+ TableIdentifier.of("default", "table" +
Long.toString(UUID.randomUUID().hashCode(), 16));
+ org.apache.iceberg.Schema schema =
+ new org.apache.iceberg.Schema(
+ Collections.singletonList(required(1, "ts",
Types.TimestampType.withZone())),
+ ImmutableSet.of(1));
+ Table table = warehouse.createTable(tableId, schema);
+ DataFile file =
+ warehouse.writeData(
+ "date.parquet", schema,
Collections.singletonList(ImmutableMap.of("ts", ts)));
+ table.newFastAppend().appendFile(file).commit();
+
+ IcebergIO.ReadRows read =
IcebergIO.readRows(catalogConfig()).from(tableId);
+ if (useIncrementalScan) {
+ read = read.withCdc().toSnapshot(table.currentSnapshot().snapshotId());
+ }
+
+ Schema expectedBeamSchema =
+ Schema.builder().addLogicalTypeField("ts", Timestamp.MICROS).build();
+ Row expectedRow =
Row.withSchema(expectedBeamSchema).addValue(ts.toInstant()).build();
+
+ PCollection<Row> output = testPipeline.apply(read).apply(new PrintRow());
+ PAssert.that(output).containsInAnyOrder(expectedRow);
+ testPipeline.run().waitUntilFinish();
+
+ // test again but with older versions that require primitive DATETIME type
+ Schema expectedLegacyBeamSchema =
Schema.builder().addDateTimeField("ts").build();
+ Row expectedLegacyRow =
+
Row.withSchema(expectedLegacyBeamSchema).addValue(DateTime.parse(val)).build();
+
+ Pipeline testPipeline2 = Pipeline.create();
+
testPipeline2.getOptions().as(StreamingOptions.class).setUpdateCompatibilityVersion("2.75.0");
+ PCollection<Row> outputLegacy = testPipeline2.apply(read).apply(new
PrintRow());
+ PAssert.that(outputLegacy).containsInAnyOrder(expectedLegacyRow);
+ testPipeline2.run().waitUntilFinish();
+ }
+
public void runWithStartingStrategy(@Nullable StartingStrategy strategy,
boolean streaming)
throws IOException {
assumeTrue(useIncrementalScan);
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergUtilsTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergUtilsTest.java
index 3da31ecc206..7e707717f3c 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergUtilsTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergUtilsTest.java
@@ -42,6 +42,7 @@ import org.apache.beam.sdk.schemas.Schema;
import org.apache.beam.sdk.schemas.logicaltypes.FixedPrecisionNumeric;
import org.apache.beam.sdk.schemas.logicaltypes.FixedString;
import org.apache.beam.sdk.schemas.logicaltypes.SqlTypes;
+import org.apache.beam.sdk.schemas.logicaltypes.Timestamp;
import org.apache.beam.sdk.schemas.logicaltypes.UuidLogicalType;
import org.apache.beam.sdk.schemas.logicaltypes.VariableBytes;
import org.apache.beam.sdk.schemas.logicaltypes.VariableString;
@@ -232,6 +233,13 @@ public class IcebergUtilsTest {
OffsetDateTime offsetDateTime = OffsetDateTime.parse(val);
LocalDateTime localDateTime =
offsetDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime();
+ // Timestamp.MICROS
+ checkRowValueToRecordValue(
+ Schema.FieldType.logicalType(Timestamp.MICROS),
+ offsetDateTime.toInstant(),
+ Types.TimestampType.withZone(),
+ offsetDateTime.withOffsetSameInstant(ZoneOffset.UTC));
+
// SqlTypes.DATETIME
checkRowValueToRecordValue(
Schema.FieldType.logicalType(SqlTypes.DATETIME),
@@ -426,6 +434,24 @@ public class IcebergUtilsTest {
OffsetDateTime offsetDateTime = OffsetDateTime.parse(timestamp);
LocalDateTime localDateTime =
offsetDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime();
+
+ // Timestamp.MICROS
+ checkRecordValueToRowValue(
+ Types.TimestampType.withZone(),
+ offsetDateTime,
+ Schema.FieldType.logicalType(Timestamp.MICROS),
+ offsetDateTime.toInstant());
+ checkRecordValueToRowValue(
+ Types.TimestampType.withZone(),
+ DateTimeUtil.microsFromTimestamptz(offsetDateTime),
+ Schema.FieldType.logicalType(Timestamp.MICROS),
+ offsetDateTime.toInstant());
+ checkRecordValueToRowValue(
+ Types.TimestampType.withZone(),
+ timestamp,
+ Schema.FieldType.logicalType(Timestamp.MICROS),
+ offsetDateTime.toInstant());
+
// SqlTypes.DATETIME
checkRecordValueToRowValue(
Types.TimestampType.withZone(),
@@ -458,6 +484,21 @@ public class IcebergUtilsTest {
Types.TimestampType.withZone(), timestamp,
Schema.FieldType.DATETIME, dateTime);
}
+ @Test
+ public void testUpdateCompatibilityVersionGatesTimestamptzMapping() {
+ org.apache.iceberg.Schema icebergSchema =
+ new org.apache.iceberg.Schema(required(0, "ts",
Types.TimestampType.withZone()));
+
+ // A pinned, older update-compatibility version keeps the legacy
DATETIME mapping on read.
+ Schema pinnedOld = IcebergUtils.icebergSchemaToBeamSchema(icebergSchema,
"2.50.0");
+ assertEquals(Schema.FieldType.DATETIME,
pinnedOld.getField("ts").getType());
+
+ // An unset (null) or future update-compatibility version uses the new
micros mapping.
+ Schema unpinned = IcebergUtils.icebergSchemaToBeamSchema(icebergSchema,
null);
+ assertEquals(
+ Schema.FieldType.logicalType(Timestamp.MICROS),
unpinned.getField("ts").getType());
+ }
+
@Test
public void testFixed() {}
@@ -868,7 +909,7 @@ public class IcebergUtilsTest {
.addNullableStringField("str")
.addNullableBooleanField("bool")
.addByteArrayField("bytes")
- .addDateTimeField("datetime_tz")
+ .addLogicalTypeField("datetime_tz", Timestamp.MICROS)
.addLogicalTypeField("datetime", SqlTypes.DATETIME)
.addLogicalTypeField("time", SqlTypes.TIME)
.addLogicalTypeField("date", SqlTypes.DATE)
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergWriteSchemaTransformProviderTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergWriteSchemaTransformProviderTest.java
index c5fc5a6b6fe..5a7aa11e10a 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergWriteSchemaTransformProviderTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergWriteSchemaTransformProviderTest.java
@@ -38,6 +38,8 @@ import java.util.Map;
import java.util.UUID;
import org.apache.beam.sdk.Pipeline;
import org.apache.beam.sdk.managed.Managed;
+import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.options.StreamingOptions;
import org.apache.beam.sdk.schemas.Schema;
import org.apache.beam.sdk.schemas.logicaltypes.SqlTypes;
import org.apache.beam.sdk.testing.PAssert;
@@ -484,7 +486,12 @@ public class IcebergWriteSchemaTransformProviderTest {
.satisfies(new VerifyOutputs(Collections.singletonList(identifier),
"append"));
testPipeline.run().waitUntilFinish();
- Pipeline p = Pipeline.create(TestPipeline.testingPipelineOptions());
+ // This table has timestamptz columns written as joda DateTime. Pin an
older update
+ // compatibility version so the read keeps the legacy DATETIME mapping and
matches the written
+ // rows
+ PipelineOptions readOptions = TestPipeline.testingPipelineOptions();
+
readOptions.as(StreamingOptions.class).setUpdateCompatibilityVersion("2.75.0");
+ Pipeline p = Pipeline.create(readOptions);
PCollection<Row> readRows =
p.apply(Managed.read(Managed.ICEBERG).withConfig(config)).getSinglePCollection();
PAssert.that(readRows).containsInAnyOrder(rows);
@@ -554,7 +561,9 @@ public class IcebergWriteSchemaTransformProviderTest {
.satisfies(new VerifyOutputs(Collections.singletonList(identifier),
"append"));
testPipeline.run().waitUntilFinish();
- Pipeline p = Pipeline.create(TestPipeline.testingPipelineOptions());
+ PipelineOptions readOptions = TestPipeline.testingPipelineOptions();
+
readOptions.as(StreamingOptions.class).setUpdateCompatibilityVersion("2.75.0");
+ Pipeline p = Pipeline.create(readOptions);
PCollection<Row> readRows =
p.apply(Managed.read(Managed.ICEBERG).withConfig(config)).getSinglePCollection();
PAssert.that(readRows).containsInAnyOrder(rows);
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/BigQueryMetastoreCatalogIT.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/BigQueryMetastoreCatalogIT.java
index eb3ebfbf521..a34e580d29b 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/BigQueryMetastoreCatalogIT.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/BigQueryMetastoreCatalogIT.java
@@ -58,6 +58,7 @@ public class BigQueryMetastoreCatalogIT extends
IcebergCatalogBaseIT {
.put("gcp_project", OPTIONS.getProject())
.put("gcp_location", "us-central1")
.put("warehouse", warehouse)
+ .put("io-impl", "org.apache.iceberg.gcp.gcs.GCSFileIO")
.build(),
new Configuration());
}
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/IcebergCatalogBaseIT.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/IcebergCatalogBaseIT.java
index f0c7ae925df..5c28f0192a6 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/IcebergCatalogBaseIT.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/catalog/IcebergCatalogBaseIT.java
@@ -59,6 +59,7 @@ import org.apache.beam.sdk.io.iceberg.IcebergUtils;
import org.apache.beam.sdk.managed.Managed;
import org.apache.beam.sdk.schemas.Schema;
import org.apache.beam.sdk.schemas.logicaltypes.SqlTypes;
+import org.apache.beam.sdk.schemas.logicaltypes.Timestamp;
import org.apache.beam.sdk.testing.PAssert;
import org.apache.beam.sdk.testing.TestPipeline;
import org.apache.beam.sdk.transforms.Create;
@@ -106,8 +107,6 @@ import org.apache.iceberg.types.TypeUtil;
import org.apache.iceberg.util.DateTimeUtil;
import org.apache.iceberg.util.PartitionUtil;
import org.checkerframework.checker.nullness.qual.Nullable;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
import org.joda.time.Duration;
import org.joda.time.Instant;
import org.joda.time.LocalDate;
@@ -272,7 +271,7 @@ public abstract class IcebergCatalogBaseIT implements
Serializable {
.addArrayField("arr_long", Schema.FieldType.INT64)
.addNullableRowField("nullable_row", NESTED_ROW_SCHEMA)
.addNullableInt64Field("nullable_long")
- .addDateTimeField("datetime_tz")
+ .addLogicalTypeField("datetime_tz", Timestamp.MICROS)
.addLogicalTypeField("datetime", SqlTypes.DATETIME)
.addLogicalTypeField("date", SqlTypes.DATE)
.addLogicalTypeField("time", SqlTypes.TIME)
@@ -309,8 +308,10 @@ public abstract class IcebergCatalogBaseIT implements
Serializable {
.addValue(LongStream.range(0, num %
10).boxed().collect(Collectors.toList()))
.addValue(num % 2 == 0 ? null : nestedRow)
.addValue(num)
- .addValue(new
DateTime(timestampMillis).withZone(DateTimeZone.forOffsetHours(4)))
- .addValue(DateTimeUtil.timestampFromMicros(timestampMillis *
1000))
+ .addValue(
+ DateTimeUtil.timestamptzFromMicros(timestampMillis * 1000 +
123456789)
+ .toInstant())
+ .addValue(DateTimeUtil.timestampFromMicros(timestampMillis *
1000 + 123456789))
.addValue(DateTimeUtil.dateFromDays(Integer.parseInt(strNum)))
.addValue(DateTimeUtil.timeFromMicros(num))
.build();
diff --git a/sdks/python/apache_beam/transforms/managed_iceberg_it_test.py
b/sdks/python/apache_beam/transforms/managed_iceberg_it_test.py
index 458855c4b96..23d19c50497 100644
--- a/sdks/python/apache_beam/transforms/managed_iceberg_it_test.py
+++ b/sdks/python/apache_beam/transforms/managed_iceberg_it_test.py
@@ -26,6 +26,7 @@ import apache_beam as beam
from apache_beam.testing.test_pipeline import TestPipeline
from apache_beam.testing.util import assert_that
from apache_beam.testing.util import equal_to
+from apache_beam.utils.timestamp import Timestamp
@pytest.mark.uses_io_java_expansion_service
@@ -51,7 +52,8 @@ class ManagedIcebergIT(unittest.TestCase):
bool_=(num % 2 == 0),
float_=(num + float(num) / 100),
arr_=[num, num, num],
- date_=datetime.date.today() - datetime.timedelta(days=num))
+ date_=datetime.date.today() - datetime.timedelta(days=num),
+ timestamp_=Timestamp(123 * num, 456 * num))
def test_write_read_pipeline(self):
biglake_catalog_props = {