This is an automated email from the ASF dual-hosted git repository.
voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new e4718be30790 fix(schema): require a per-field override to promote a
bare long to a timestamp logical type (#19384)
e4718be30790 is described below
commit e4718be307900b5348f26bc113885f224c33896c
Author: Y Ethan Guo <[email protected]>
AuthorDate: Mon Aug 3 21:38:48 2026 -0700
fix(schema): require a per-field override to promote a bare long to a
timestamp logical type (#19384)
Promoting a bare long column to a timestamp logical type during
writer-schema deduction is
now uniformly gated behind the per-field override
hoodie.write.timestamp.logical.type.overrides:
rejected with an actionable error when the field has no override, and
applied when it does.
This holds for all four target types (timestamp-micros, timestamp-millis,
local-timestamp-micros, local-timestamp-millis) and in both reconcile paths
(reconcileSchema
and reconcileTimestampLogicalType).
Previously, long to local-timestamp was already override-gated, but long to
UTC-timestamp was
silently allowed on the default (non-reconcile) write path, because
isGatedTimestampChange did
not treat it as a gated change. A bare long carries no precision signal
(millis vs micros), so
silently attaching a UTC timestamp logical type could mislabel stored
values. This makes the two
cases behave identically. Timestamp precision flips are unchanged.
timestampPrecisionChangeError is made public so tests assert the exact
message without
duplicating its format.
Tests: TestSchemaChangeUtils and TestAvroSchemaEvolutionUtils cover the
gating on both reconcile
paths, with and without an override, for all four targets including nested
fields;
TestHoodieDeltaStreamer.testLongToTimestampPromotionGated exercises the
promotion end to end.
---
.../hudi/common/config/HoodieCommonConfig.java | 7 +-
.../internal/utils/AvroSchemaEvolutionUtils.java | 7 +-
.../schema/internal/utils/SchemaChangeUtils.java | 13 +--
.../utils/TestAvroSchemaEvolutionUtils.java | 100 +++++++++++++++++++++
.../internal/utils/TestSchemaChangeUtils.java | 29 ++++++
.../deltastreamer/TestHoodieDeltaStreamer.java | 98 ++++++++++++++++++++
6 files changed, 244 insertions(+), 10 deletions(-)
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieCommonConfig.java
b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieCommonConfig.java
index 6724128d3e7f..47e967ad6d08 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieCommonConfig.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieCommonConfig.java
@@ -94,9 +94,10 @@ public class HoodieCommonConfig extends HoodieConfig {
+ "entry is pinned to that logical type: an incoming value of a
different precision is coerced to it, and "
+ "the change from the table's current type is permitted. A
timestamp precision change with no entry for "
+ "the field is rejected with an error, so an unverified
micros/millis flip can never happen silently. "
- + "An entry also attaches a local-timestamp logical type to a column
that 0.x persisted as a bare long "
- + "because its converter did not recognize the type. A UTC/local
zone change is never authorized by "
- + "this config, whatever the entry says, since no rescale can
express it. "
+ + "An entry also attaches a timestamp logical type (UTC or local) to
a column persisted as a bare "
+ + "long, including one that 0.x stored without a logical type
because its converter did not recognize "
+ + "it. A UTC/local zone change is never authorized by this config,
whatever the entry says, since no "
+ + "rescale can express it. "
+ "Derive the value from the stored longs, never from the incoming
schema: for instants after 1990 an "
+ "epoch-millis value is around 1e12 while epoch-micros is around
1e15, so the two ranges do not "
+ "overlap. TimestampLogicalTypeClassifier implements that verdict
for inspection tooling to reuse. "
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/AvroSchemaEvolutionUtils.java
b/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/AvroSchemaEvolutionUtils.java
index 947fc72df797..0310ccfb236e 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/AvroSchemaEvolutionUtils.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/AvroSchemaEvolutionUtils.java
@@ -277,7 +277,12 @@ public class AvroSchemaEvolutionUtils {
col, from, to,
HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key()));
}
- private static SchemaCompatibilityException
timestampPrecisionChangeError(String col, Type from, Type to) {
+ /**
+ * Builds the actionable error for a gated timestamp logical-type change
with no per-field override
+ * in {@code hoodie.write.timestamp.logical.type.overrides}. Public so tests
can assert the exact
+ * message without duplicating its format.
+ */
+ public static SchemaCompatibilityException
timestampPrecisionChangeError(String col, Type from, Type to) {
return new SchemaCompatibilityException(String.format(
"Refusing to change the timestamp logical type of column '%s' from
'%s' to '%s' without an explicit "
+ "verdict. This precision change is not applied automatically
because the correct target depends "
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/SchemaChangeUtils.java
b/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/SchemaChangeUtils.java
index 567c1397f431..f7d8ba142dde 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/SchemaChangeUtils.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/SchemaChangeUtils.java
@@ -96,8 +96,8 @@ public class SchemaChangeUtils {
/**
* Whether a column type change is a timestamp precision change that must be
authorized by an
* explicit per-field override (see {@code
hoodie.write.timestamp.logical.type.overrides}). This
- * covers timestamp-micros/millis flips, local-timestamp-micros/millis
flips, and the forward-fix
- * from a bare {@code long} to a local-timestamp logical type that 0.x
dropped.
+ * covers timestamp-micros/millis flips, local-timestamp-micros/millis
flips, and promoting a bare
+ * {@code long} to a timestamp logical type (UTC or local).
*/
public static boolean isGatedTimestampChange(Type src, Type dst) {
if (src.equals(dst)) {
@@ -109,7 +109,7 @@ public class SchemaChangeUtils {
if (isLocalTimestamp(src) && isLocalTimestamp(dst)) {
return true;
}
- return src.typeId() == Type.TypeID.LONG && isLocalTimestamp(dst);
+ return src.typeId() == Type.TypeID.LONG && (isUtcTimestamp(dst) ||
isLocalTimestamp(dst));
}
/**
@@ -170,9 +170,10 @@ public class SchemaChangeUtils {
|| dst == Types.DoubleType.get() || dst == Types.StringType.get()
|| dst.typeId() == Type.TypeID.DECIMAL || dst.typeId() ==
Type.TypeID.DECIMAL_FIXED;
case LONG:
if (allowTimestampPrecisionEvolution
- && (dst.typeId() == Type.TypeID.LOCAL_TIMESTAMP_MILLIS ||
dst.typeId() == Type.TypeID.LOCAL_TIMESTAMP_MICROS)) {
- // Forward-fix path: 0.x stored local-timestamp columns as bare long
because its converter
- // did not recognize the logical type. Allow attaching the logical
type when the gate is open.
+ && (dst.typeId() == Type.TypeID.TIMESTAMP || dst.typeId() ==
Type.TypeID.TIMESTAMP_MILLIS
+ || dst.typeId() == Type.TypeID.LOCAL_TIMESTAMP_MILLIS ||
dst.typeId() == Type.TypeID.LOCAL_TIMESTAMP_MICROS)) {
+ // A bare long carries no precision signal, so promoting it to a
timestamp logical type is
+ // authorized only by an explicit per-field override.
return true;
}
return dst == Types.FloatType.get() || dst == Types.DoubleType.get()
|| dst == Types.StringType.get() || dst.typeId() == Type.TypeID.DECIMAL ||
dst.typeId() == Type.TypeID.DECIMAL_FIXED;
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestAvroSchemaEvolutionUtils.java
b/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestAvroSchemaEvolutionUtils.java
index 83f0e47af648..92dc75303780 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestAvroSchemaEvolutionUtils.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestAvroSchemaEvolutionUtils.java
@@ -51,6 +51,7 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -840,4 +841,103 @@ public class TestAvroSchemaEvolutionUtils {
SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:local-timestamp-millis")).toAvroSchema();
Assertions.assertEquals("local-timestamp-millis",
stillWorks.getField("ts").schema().getLogicalType().getName());
}
+
+ @Test
+ void testLongToUtcTimestampGatedInBothReconcilePaths() {
+ // Bare long to a UTC timestamp is override-gated exactly like the
local-timestamp case: rejected
+ // without a per-field override and applied with one, in both reconcile
paths. The non-reconcile
+ // guard previously skipped this and let it through silently on the
default write path.
+ HoodieSchema tableBareLong =
HoodieSchema.fromAvroSchema(tripAvro(Schema.create(Schema.Type.LONG)));
+ HoodieSchema incomingMicros =
HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.timestampMicros().addToSchema(Schema.create(Schema.Type.LONG))));
+
+ // No override: rejected in both paths with the exact actionable error.
+ Map<String, Type> noOverride =
SchemaChangeUtils.parseTimestampLogicalTypeOverrides("");
+ String expectedError =
AvroSchemaEvolutionUtils.timestampPrecisionChangeError(
+ "ts", Types.LongType.get(), Types.TimestampType.get()).getMessage();
+ SchemaCompatibilityException reconcileError =
assertThrows(SchemaCompatibilityException.class,
+ () -> AvroSchemaEvolutionUtils.reconcileSchema(incomingMicros,
tableBareLong, false, noOverride));
+ assertEquals(expectedError, reconcileError.getMessage());
+ SchemaCompatibilityException guardError =
assertThrows(SchemaCompatibilityException.class,
+ () ->
AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incomingMicros,
tableBareLong, noOverride));
+ assertEquals(expectedError, guardError.getMessage());
+
+ // With the override: the promotion is applied in both paths.
+ Schema viaReconcile =
AvroSchemaEvolutionUtils.reconcileSchema(incomingMicros, tableBareLong, false,
+
SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-micros")).toAvroSchema();
+ assertEquals("timestamp-micros",
viaReconcile.getField("ts").schema().getLogicalType().getName());
+ Schema viaGuard =
AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incomingMicros,
tableBareLong,
+
SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:timestamp-micros")).toAvroSchema();
+ assertEquals("timestamp-micros",
viaGuard.getField("ts").schema().getLogicalType().getName());
+ }
+
+ @Test
+ void testLongToLocalTimestampGatedInBothReconcilePaths() {
+ // Bare long to local timestamp is override-gated (not forbidden):
rejected without an override
+ // and applied with one, and the non-reconcile guard must agree with
reconcileSchema.
+ HoodieSchema tableBareLong =
HoodieSchema.fromAvroSchema(tripAvro(Schema.create(Schema.Type.LONG)));
+ HoodieSchema incomingLocalMicros =
HoodieSchema.fromAvroSchema(tripAvro(LogicalTypes.localTimestampMicros().addToSchema(Schema.create(Schema.Type.LONG))));
+
+ // No override: rejected in both paths with the exact actionable error.
+ Map<String, Type> noOverride =
SchemaChangeUtils.parseTimestampLogicalTypeOverrides("");
+ String expectedError =
AvroSchemaEvolutionUtils.timestampPrecisionChangeError(
+ "ts", Types.LongType.get(),
Types.LocalTimestampMicrosType.get()).getMessage();
+ SchemaCompatibilityException reconcileError =
assertThrows(SchemaCompatibilityException.class,
+ () -> AvroSchemaEvolutionUtils.reconcileSchema(incomingLocalMicros,
tableBareLong, false, noOverride));
+ assertEquals(expectedError, reconcileError.getMessage());
+ SchemaCompatibilityException guardError =
assertThrows(SchemaCompatibilityException.class,
+ () ->
AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incomingLocalMicros,
tableBareLong, noOverride));
+ assertEquals(expectedError, guardError.getMessage());
+ Schema repaired =
AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incomingLocalMicros,
tableBareLong,
+
SchemaChangeUtils.parseTimestampLogicalTypeOverrides("ts:local-timestamp-micros")).toAvroSchema();
+ assertEquals("local-timestamp-micros",
repaired.getField("ts").schema().getLogicalType().getName());
+ }
+
+ @Test
+ void testNestedLongToTimestampGated() {
+ // The gate resolves fully-qualified column names, so it applies to nested
fields too. A nested
+ // long -> timestamp (UTC or local) is override-gated via the dotted-key
override.
+ for (String token : new String[] {"timestamp-micros",
"local-timestamp-millis"}) {
+ HoodieSchema tableNested =
HoodieSchema.fromAvroSchema(nestedTrip(Schema.create(Schema.Type.LONG)));
+ HoodieSchema incoming =
HoodieSchema.fromAvroSchema(nestedTrip(logicalLong(token)));
+ // No override: rejected in both paths with the exact actionable error.
+ Map<String, Type> noOverride =
SchemaChangeUtils.parseTimestampLogicalTypeOverrides("");
+ String expectedError =
AvroSchemaEvolutionUtils.timestampPrecisionChangeError("payload.event_ts",
Types.LongType.get(),
+ SchemaChangeUtils.parseTimestampLogicalTypeOverrides("field:" +
token).get("field")).getMessage();
+ SchemaCompatibilityException reconcileError =
assertThrows(SchemaCompatibilityException.class,
+ () -> AvroSchemaEvolutionUtils.reconcileSchema(incoming,
tableNested, false, noOverride));
+ assertEquals(expectedError, reconcileError.getMessage());
+ SchemaCompatibilityException guardError =
assertThrows(SchemaCompatibilityException.class,
+ () ->
AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incoming, tableNested,
noOverride));
+ assertEquals(expectedError, guardError.getMessage());
+ // The dotted-key override authorizes the nested promotion.
+ Schema repairedNested =
AvroSchemaEvolutionUtils.reconcileTimestampLogicalType(incoming, tableNested,
+
SchemaChangeUtils.parseTimestampLogicalTypeOverrides("payload.event_ts:" +
token)).toAvroSchema();
+ assertEquals(token,
+
repairedNested.getField("payload").schema().getField("event_ts").schema().getLogicalType().getName());
+ }
+ }
+
+ private static Schema nestedTrip(Schema eventTsType) {
+ Schema payload = Schema.createRecord("payloadrec", null, null, false,
Arrays.asList(
+ new Schema.Field("event_ts", eventTsType, null, null)));
+ return Schema.createRecord("trip", null, null, false, Arrays.asList(
+ new Schema.Field("id", Schema.create(Schema.Type.STRING), null, null),
+ new Schema.Field("payload", payload, null, null)));
+ }
+
+ private static Schema logicalLong(String token) {
+ Schema longSchema = Schema.create(Schema.Type.LONG);
+ switch (token) {
+ case "timestamp-micros":
+ return LogicalTypes.timestampMicros().addToSchema(longSchema);
+ case "timestamp-millis":
+ return LogicalTypes.timestampMillis().addToSchema(longSchema);
+ case "local-timestamp-micros":
+ return LogicalTypes.localTimestampMicros().addToSchema(longSchema);
+ case "local-timestamp-millis":
+ return LogicalTypes.localTimestampMillis().addToSchema(longSchema);
+ default:
+ throw new IllegalArgumentException(token);
+ }
+ }
}
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestSchemaChangeUtils.java
b/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestSchemaChangeUtils.java
index 9e0579214bea..522e2559a967 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestSchemaChangeUtils.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestSchemaChangeUtils.java
@@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -120,4 +121,32 @@ public class TestSchemaChangeUtils {
assertThrows(UnsupportedOperationException.class,
() -> overrides.put("other", Types.TimestampMillisType.get()));
}
+
+ @Test
+ void gatedTimestampChangeCoversFlipsAndLongPromotions() {
+ // Precision flips (either direction) are gated.
+
assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.TimestampType.get(),
Types.TimestampMillisType.get()));
+
assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.LocalTimestampMicrosType.get(),
Types.LocalTimestampMillisType.get()));
+ // Promoting a bare long to any timestamp logical type (UTC or local) is
gated.
+ assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.LongType.get(),
Types.TimestampType.get()));
+ assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.LongType.get(),
Types.TimestampMillisType.get()));
+ assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.LongType.get(),
Types.LocalTimestampMillisType.get()));
+ assertTrue(SchemaChangeUtils.isGatedTimestampChange(Types.LongType.get(),
Types.LocalTimestampMicrosType.get()));
+ // Unrelated promotions and identical types are not gated.
+ assertFalse(SchemaChangeUtils.isGatedTimestampChange(Types.LongType.get(),
Types.StringType.get()));
+ assertFalse(SchemaChangeUtils.isGatedTimestampChange(Types.IntType.get(),
Types.TimestampType.get()));
+
assertFalse(SchemaChangeUtils.isGatedTimestampChange(Types.TimestampType.get(),
Types.TimestampType.get()));
+ }
+
+ @Test
+ void typeUpdateAllowGatesLongToTimestampBehindTheOverride() {
+ // Promoting a bare long to any timestamp logical type is allowed only
when the gate is open.
+ for (Type ts : new Type[] {Types.TimestampType.get(),
Types.TimestampMillisType.get(),
+ Types.LocalTimestampMillisType.get(),
Types.LocalTimestampMicrosType.get()}) {
+ assertTrue(SchemaChangeUtils.isTypeUpdateAllow(Types.LongType.get(), ts,
true));
+ assertFalse(SchemaChangeUtils.isTypeUpdateAllow(Types.LongType.get(),
ts, false));
+ }
+ // Existing long widening is unaffected by the gate.
+ assertTrue(SchemaChangeUtils.isTypeUpdateAllow(Types.LongType.get(),
Types.DoubleType.get(), false));
+ }
}
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
index 86165740dcbc..16aeb35971e5 100644
---
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java
@@ -54,6 +54,10 @@ import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.schema.HoodieSchema.TimePrecision;
import org.apache.hudi.common.schema.HoodieSchemaField;
import org.apache.hudi.common.schema.HoodieSchemaType;
+import org.apache.hudi.common.schema.internal.Type;
+import org.apache.hudi.common.schema.internal.Types;
+import org.apache.hudi.common.schema.internal.utils.AvroSchemaEvolutionUtils;
+import org.apache.hudi.common.schema.internal.utils.SchemaChangeUtils;
import org.apache.hudi.common.table.HoodieTableConfig;
import org.apache.hudi.common.table.HoodieTableMetaClient;
import org.apache.hudi.common.table.HoodieTableVersion;
@@ -132,6 +136,8 @@ import org.apache.hudi.utilities.transform.Transformer;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
+import org.apache.avro.LogicalType;
+import org.apache.avro.LogicalTypes;
import org.apache.avro.Schema;
import org.apache.avro.generic.GenericRecord;
import org.apache.avro.generic.IndexedRecord;
@@ -201,6 +207,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -760,6 +767,97 @@ public class TestHoodieDeltaStreamer extends
HoodieDeltaStreamerTestBase {
assertEquals(0,
sqlContext.read().options(hudiOpts).format("org.apache.hudi").load(tableBasePath).filter("current_ts
< '1980-01-01'").count());
}
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testLongToTimestampPromotionGated(boolean setNullForMissingColumns)
throws Exception {
+ // Promoting a plain long column to a timestamp logical type is
override-gated: rejected without a
+ // per-field override for every target type, and applied with one. A bare
long carries no precision
+ // signal, so the override is the explicit verdict that authorizes the
promotion. One bare-long seed
+ // is reused: the rejection cases all throw (table stays bare long), and
the accepted case runs last.
+ String tableBasePath = basePath + "/testLongToTs" +
setNullForMissingColumns;
+ defaultSchemaProviderClassName = FilebasedSchemaProvider.class.getName();
+
+ // Sync 0: seed the table with `seconds_since_epoch` stored as a bare long.
+ HoodieDeltaStreamer.Config seed = TestHelpers.makeConfig(tableBasePath,
WriteOperationType.INSERT,
+ Collections.singletonList(TestIdentityTransformer.class.getName()),
PROPS_FILENAME_TEST_SOURCE,
+ false, true, false, null, HoodieTableType.COPY_ON_WRITE.name());
+ seed.configs.add("hoodie.streamer.schemaprovider.source.schema.file=" +
basePath + "/source-timestamp-millis.avsc");
+ seed.configs.add("hoodie.streamer.schemaprovider.target.schema.file=" +
basePath + "/source-timestamp-millis.avsc");
+ seed.configs.add("hoodie.datasource.write.row.writer.enable=false");
+ seed.configs.add(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS.key() +
"=" + setNullForMissingColumns);
+ new HoodieDeltaStreamer(seed, jsc).sync();
+
+ Schema tableSchema = new
TableSchemaResolver(HoodieTestUtils.createMetaClient(storage, tableBasePath))
+ .getTableSchema(false).toAvroSchema();
+
assertNull(tableSchema.getField("seconds_since_epoch").schema().getLogicalType(),
+ "seconds_since_epoch must be seeded as a bare long in the table");
+ Schema baseSchema = new Schema.Parser().parse(fs.open(new Path(basePath +
"/source-timestamp-millis.avsc")));
+
+ // Every target type is rejected without a per-field override.
+ for (LogicalType targetType : new LogicalType[]
{LogicalTypes.timestampMillis(), LogicalTypes.timestampMicros(),
+ LogicalTypes.localTimestampMillis(),
LogicalTypes.localTimestampMicros()}) {
+ String schemaFile = writePromotedSchema(baseSchema, targetType,
setNullForMissingColumns);
+ HoodieDeltaStreamer.Config reject = promoteConfig(tableBasePath,
schemaFile, setNullForMissingColumns, null);
+ HoodieDeltaStreamer streamer = new HoodieDeltaStreamer(reject, jsc);
+ // sync() wraps the guard's SchemaCompatibilityException in a
HoodieIngestionException, so walk
+ // the cause chain to assert on the underlying exception.
+ Throwable thrown = assertThrows(Exception.class, streamer::sync,
+ "long -> " + targetType.getName() + " must be rejected without an
override");
+ Throwable cause = thrown;
+ while (cause != null && !(cause instanceof
SchemaCompatibilityException)) {
+ cause = cause.getCause();
+ }
+ assertTrue(cause instanceof SchemaCompatibilityException,
+ "Expected a SchemaCompatibilityException in the cause chain, got: "
+ thrown);
+ Type toType =
SchemaChangeUtils.parseTimestampLogicalTypeOverrides("field:" +
targetType.getName()).get("field");
+ assertEquals(AvroSchemaEvolutionUtils.timestampPrecisionChangeError(
+ "seconds_since_epoch", Types.LongType.get(), toType).getMessage(),
cause.getMessage());
+ }
+
+ // With an override the promotion is authorized (local promotions are
covered end-to-end by
+ // testCOWLogicalRepair / testMORLogicalRepair); verify a UTC promotion
succeeds and lands on the
+ // table schema.
+ String utcSchemaFile = writePromotedSchema(baseSchema,
LogicalTypes.timestampMicros(), setNullForMissingColumns);
+ HoodieDeltaStreamer.Config accept = promoteConfig(tableBasePath,
utcSchemaFile, setNullForMissingColumns,
+ "seconds_since_epoch:timestamp-micros");
+ new HoodieDeltaStreamer(accept, jsc).sync();
+ Schema evolved = new
TableSchemaResolver(HoodieTestUtils.createMetaClient(storage, tableBasePath))
+ .getTableSchema(false).toAvroSchema();
+ assertEquals("timestamp-micros",
evolved.getField("seconds_since_epoch").schema().getLogicalType().getName());
+ }
+
+ private String writePromotedSchema(Schema baseSchema, LogicalType
targetType, boolean setNull) throws IOException {
+ Schema incoming = replaceFieldType(baseSchema, "seconds_since_epoch",
+ targetType.addToSchema(Schema.create(Schema.Type.LONG)));
+ String schemaFile = basePath + "/promote-" + targetType.getName() + "-nul"
+ setNull + ".avsc";
+ UtilitiesTestBase.Helpers.saveStringsToDFS(new String[]
{incoming.toString()}, storage, schemaFile);
+ return schemaFile;
+ }
+
+ private HoodieDeltaStreamer.Config promoteConfig(String tableBasePath,
String schemaFile,
+ boolean
setNullForMissingColumns, String override) {
+ HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(tableBasePath,
WriteOperationType.UPSERT,
+ Collections.singletonList(TestIdentityTransformer.class.getName()),
PROPS_FILENAME_TEST_SOURCE,
+ false, true, false, null, HoodieTableType.COPY_ON_WRITE.name());
+ cfg.configs.add("hoodie.streamer.schemaprovider.source.schema.file=" +
schemaFile);
+ cfg.configs.add("hoodie.streamer.schemaprovider.target.schema.file=" +
schemaFile);
+ cfg.configs.add("hoodie.datasource.write.row.writer.enable=false");
+ cfg.configs.add(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS.key() +
"=" + setNullForMissingColumns);
+ if (override != null) {
+
cfg.configs.add(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key() + "="
+ override);
+ }
+ return cfg;
+ }
+
+ private static Schema replaceFieldType(Schema recordSchema, String
fieldName, Schema newFieldType) {
+ List<Schema.Field> fields = new ArrayList<>();
+ for (Schema.Field field : recordSchema.getFields()) {
+ Schema fieldSchema = field.name().equals(fieldName) ? newFieldType :
field.schema();
+ fields.add(new Schema.Field(field.name(), fieldSchema, field.doc(),
field.defaultVal()));
+ }
+ return Schema.createRecord(recordSchema.getName(), recordSchema.getDoc(),
recordSchema.getNamespace(), false, fields);
+ }
+
@Test
public void testLogicalTypes() throws Exception {
try {