voonhous commented on code in PR #19384:
URL: https://github.com/apache/hudi/pull/19384#discussion_r3673426427


##########
hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/SchemaChangeUtils.java:
##########
@@ -170,9 +170,10 @@ private static boolean isTypeUpdateAllowInternal(Type src, 
Type dst, boolean all
             || 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

Review Comment:
   The read side was never widened to match this. Both repair implementations 
fix up "file column is a bare long, table says timestamp" **only when the table 
type is local**:
   
   - `SchemaRepair.java:151` -- `&& !((TimestampLogicalTypeAnnotation) 
tableLogicalTypeAnnotation).isAdjustedToUTC()`
   - `HoodieSchemaRepair.java:126` -- `return !tableTimestamp.isUtcAdjusted();`
   
   Compiled `hudi-common` at this commit and ran the two 
`needsLogicalTypeRepair` paths directly:
   
   ```
   file bare long        -> table timestamp-micros (UTC)   repaired=false  -> 
"long"
   file bare long        -> table local-timestamp-micros   repaired=true   -> 
local-timestamp-micros
   file timestamp-micros -> table timestamp-millis         repaired=true   -> 
timestamp-millis
   ```
   
   That restriction came in with `fd79a1682e7e` (#14161) and was right at the 
time: 0.x only dropped the *local* logical types, so "bare-long file + 
UTC-timestamp table" could not arise. This PR makes it arise. 
`TestHoodieSchemaRepair` has 
`testRepairLongWithoutLogicalTypeToLocalTimestampMillis` / `...Micros` and no 
UTC equivalent for the same reason.
   
   Net effect: once a UTC promotion is authorized, every base file written 
before it holds unannotated INT64 under a `timestamp-micros` table schema and 
nothing fixes it up on read. It also makes the untouched line at 
`HoodieCommonConfig.java:105` -- "Existing base files keep the old logical 
type; Hudi readers compensate for it" -- false for exactly the case this PR 
adds.
   
   Two ways out, either works:
   
   1. Drop `TIMESTAMP` / `TIMESTAMP_MILLIS` from this allowance and keep the 
promotion local-only.
   2. Widen `needsLogicalTypeRepair` in both classes to cover unannotated INT64 
-> UTC timestamp, mirror 
`testRepairLongWithoutLogicalTypeToLocalTimestampMillis` for the UTC targets, 
and reword the `HoodieCommonConfig` line so it stops promising reader 
compensation that is not there. Note 
`HoodieFileGroupReaderBasedFileFormat.scala:95` gates repair on 
`hasTimestampMillisField`, so that gate needs loosening too.
   
   The read-back I asked for on the streamer test will tell you which of these 
you are dealing with.



##########
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 "

Review Comment:
   This promise is breakable after the change, on the paths that call 
`reconcileSchema` without the Spark pre-pass.
   
   `isTypeUpdateAllow(LONG, TIMESTAMP, true)` returns `true` now; on master it 
returned `false`, and that `false` is what rejected the case below. Take a 
bare-long table column, an incoming `local-timestamp-micros` field, and 
`col:timestamp-micros` in the override:
   
   1. `isGatedTimestampChange(LONG, local-timestamp-micros)` -> `true`
   2. `applyTimestampOverrideOrThrow` is called with `skipIfEquals = tableType 
= LONG`, the override is not `LONG`, so it calls 
`typeChange.updateColumnType(col, TimestampType)` on a field that is still 
`LONG`
   3. `TableChanges.java:97` gates that on `isTypeUpdateAllow(LONG, TIMESTAMP, 
true)` -- `true` after this PR, so it goes through
   
   The table schema ends up UTC while the writer declares local. 
`isCrossZoneTimestampChange` never fires because it needs timestamps on both 
sides (`SchemaChangeUtils.java:119-121`) and the table side is a bare long.
   
   Reachable from `BaseHoodieWriteClient.java:375`, 
`HoodieMergeHelper.java:174` and `FileGroupReaderBasedMergeHandle.java:284` -- 
all three read the override from the write config, and none of them run 
`reconcileTimestampLogicalType` first (that call exists only at 
`HoodieSchemaUtils.scala:156`, the Spark datasource path).
   
   Suggest rejecting in `applyTimestampOverrideOrThrow` when the override's 
zone disagrees with the incoming field's zone, raising 
`crossZoneTimestampChangeError` rather than the generic "incompatible type" 
message, and adding a `reconcileSchema(table=long, 
incoming=local-timestamp-micros, override=timestamp-micros)` case to 
`TestAvroSchemaEvolutionUtils`.



##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java:
##########
@@ -760,6 +767,97 @@ public void testTimestampMillis() throws Exception {
     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());

Review Comment:
   This asserts the schema landed but never reads the table, so the state 
described in my `SchemaChangeUtils` comment -- sync-0 base files holding 
unannotated INT64 under a `timestamp-micros` table schema -- is created here 
and then never put through a reader. The write goes down the Avro path, which 
passes raw INT64 straight through, so the test stays green whether or not the 
table is readable afterwards.
   
   Every neighbour reads back: `testTimestampMillis:765` does 
`sqlContext.read()...load(tableBasePath).filter("current_ts > 
'1980-01-01'").count()`, and `testCOWLogicalRepair` from #19029 does 
`assertDataframe(df, 15, 15)`.
   
   Please add the read-back:
   
   ```suggestion
       assertEquals("timestamp-micros", 
evolved.getField("seconds_since_epoch").schema().getLogicalType().getName());
       // The sync-0 base files still hold unannotated INT64 under a 
timestamp-micros table schema --
       // make sure they are still readable after the promotion.
       sqlContext.clearCache();
       
sqlContext.read().format("org.apache.hudi").load(tableBasePath).select("seconds_since_epoch").collect();
   ```
   
   One thing to be aware of if you also want to assert values: 
`seconds_since_epoch` is seeded from `rand.nextLong()` 
(`HoodieTestDataGenerator.java:582`), so any value assertion is meaningless 
unless you seed it with a deterministic epoch-micros value first.



##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java:
##########
@@ -760,6 +767,97 @@ public void testTimestampMillis() throws Exception {
     assertEquals(0, 
sqlContext.read().options(hudiOpts).format("org.apache.hudi").load(tableBasePath).filter("current_ts
 < '1980-01-01'").count());
   }
 
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})

Review Comment:
   Two things about the cost of this test.
   
   **The parameterization is inert for 5 of the 6 syncs it runs.** 
`HoodieSchemaUtils.scala:156` calls `reconcileTimestampLogicalType` 
unconditionally, *before* the `if (shouldReconcileSchema)` branch and before 
`setNullForMissingColumns` is read at `:188` / `:226`. All four rejection cases 
throw in that upfront guard, so the flag is never consulted; the seed sync hits 
`latestTableSchemaOpt == None` at `:121`, also flag-independent. Only the 
accept leg differs. So the boolean doubles a heavyweight Spark test to re-run 
five identical syncs.
   
   **There is a cheaper home that already exists.** 
`TestHoodieSchemaUtils.java:343-359` (hudi-spark-common, no SparkContext) 
already exposes `deduceWriterSchema(incoming, table, setNullForMissingColumns)` 
backed by `TypedProperties`, and `:324-341` is already a `@ParameterizedTest 
@ValueSource(booleans = {true, false})` over the same flag. 
`TestHoodieDeltaStreamer` has 99 test methods and runs in the 120-minute "UT 
Hudi Streamer & FT utilities" Azure job. Config threading and exception 
surfacing end-to-end are already proven by the `testCOWLogicalRepair` / 
`testMORLogicalRepair` rows.
   
   Suggest moving the 4-target reject matrix into `TestHoodieSchemaUtils` with 
the override added to the props, and keeping at most one unparameterized E2E 
smoke case here -- seed, one UTC reject, one accept, with the read-back from my 
other comment. That is 3 syncs instead of 12. Same complaint has been actioned 
before in `960c3955892c` (#10492) and `eeccdf9bb0f2` (#10795).



##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java:
##########
@@ -760,6 +767,97 @@ public void testTimestampMillis() throws Exception {
     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)) {

Review Comment:
   This cause-chain walk is now in the file three times -- here, `:1284` and 
`:1435`, the latter two from #19029.
   
   Nit: worth extracting one helper while you are in here, something like
   
   ```java
   private static SchemaCompatibilityException 
assertSchemaCompatCause(Executable executable) { ... }
   ```
   
   and using it at all three sites.



##########
hudi-common/src/main/java/org/apache/hudi/common/schema/internal/utils/AvroSchemaEvolutionUtils.java:
##########
@@ -277,7 +277,12 @@ private static SchemaCompatibilityException 
crossZoneTimestampChangeError(String
         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) {

Review Comment:
   Worth tagging rather than explaining in prose -- the repo has 
`org.apache.hudi.common.util.VisibleForTesting` and 22 files in 
`hudi-common/src/main` use it, including `InternalSchemaConverter.java:31,112` 
in this same package tree.
   
   ```suggestion
     @VisibleForTesting
     public static SchemaCompatibilityException 
timestampPrecisionChangeError(String col, Type from, Type to) {
   ```
   
   and drop the "Public so tests can assert the exact message without 
duplicating its format." sentence from the javadoc, since the annotation says 
it. `public` rather than package-private is right here, the consumer is in 
`hudi-utilities`.
   
   For what it is worth, I checked whether asserting the message by calling the 
producer is tautological and it is not -- pre-PR the same call throws `Cannot 
update column 'ts' from type 'long' to incompatible type 'timestamp'.` from 
`TableChanges.java:98`, so the assertion does discriminate.



##########
hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestAvroSchemaEvolutionUtils.java:
##########
@@ -840,4 +841,103 @@ public void testCrossZoneTimestampChangeIsRejected() {
         
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() {

Review Comment:
   This one guards #19029's behaviour rather than this PR's. I spliced the two 
new test files onto `d6ca44ed1279^` (plus only the `private -> public` line so 
they compile) and ran them: `TestAvroSchemaEvolutionUtils` gives `Tests run: 
22, Failures: 2` and the two failures are 
`testLongToUtcTimestampGatedInBothReconcilePaths:859` and 
`testNestedLongToTimestampGated:908`. **This test passes against pre-PR code.**
   
   Its `reconcileSchema` legs also restate what 
`testReconcileSchemaTimestampPrecisionEvolution` already asserts at `:693-704` 
-- bare long rejected with no override, applied with 
`ts:local-timestamp-millis` / `ts:local-timestamp-micros`.
   
   The only genuinely uncovered bit is the `reconcileTimestampLogicalType` leg 
for the local case. Please delete this test and add those two lines to the 
existing block at `:693-704` instead.



##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java:
##########
@@ -760,6 +767,97 @@ public void testTimestampMillis() throws Exception {
     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");

Review Comment:
   This line does nothing. The streamer's row-writer switch is 
`hoodie.streamer.write.row.writer.enable` (`StreamSync.java:688`) and it only 
applies to `BULK_INSERT`; this test uses INSERT and UPSERT. It looks like it 
was copied from `testTimestampMillis:723`.
   
   Harmless, but it reads as if the test is deliberately steering around the 
row-writer path when it is not -- and the row-writer path does get the gate 
anyway (`HoodieSparkSqlWriter.scala:466` computes the writer schema before the 
`:515` short-circuit). Nit, feel free to ignore, but I would drop it from both 
configs.



##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamer.java:
##########
@@ -760,6 +767,97 @@ public void testTimestampMillis() throws Exception {
     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());

Review Comment:
   Both this and the seed config hard-code `COPY_ON_WRITE`, so the MOR side of 
the new promotion is untested. `testMORLogicalRepair` does cover MOR + 
compaction + parquet/avro log blocks, but only for the precision flip and the 
`long -> local` promotion (`LOGICAL_REPAIR_TS_OVERRIDES`).
   
   The MOR branch matters here specifically: `HoodieAvroDataBlock.java:199` 
calls the same `HoodieSchemaRepair.repairLogicalTypes(writerSchema, 
readerSchema)` that is a no-op for UTC targets, so a pre-promotion log block 
read under a post-promotion reader schema goes unrepaired.
   
   Either parameterize this test over `{COPY_ON_WRITE, MERGE_ON_READ}`, or add 
one MOR row to `testMORLogicalRepair` with a bare-long seed and 
`seconds_since_epoch:timestamp-micros` and inline compaction on.



##########
hudi-common/src/test/java/org/apache/hudi/common/schema/internal/utils/TestAvroSchemaEvolutionUtils.java:
##########
@@ -840,4 +841,103 @@ public void testCrossZoneTimestampChangeIsRejected() {
         
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"}) {

Review Comment:
   `local-timestamp-millis` here tests pre-existing behaviour -- narrow the 
loop to just that token, run it against `d6ca44ed1279^`, and you get `Tests 
run: 2, Failures: 0`. Only the `timestamp-micros` iteration is new.
   
   Dropping it makes the loop single-token, which lets you delete `logicalLong` 
entirely and inline the one type you need -- that also resolves the naming nit 
the bot raised, better than renaming it. `HoodieSchema.createTimestampMicros()` 
(`HoodieSchema.java:684`) is right there if you want a factory. Similarly 
`nestedTrip` re-implements `HoodieSchemaTestUtils.createRecord` + 
`createNestedField` from the same module.
   
   Separately, worth spending the lines you save here on array and map 
elements. I checked and the gate does reach them -- 
`InternalSchema.getAllColsFullName()` emits `arr.element` and `mp.value`, and 
the dotted override applies on both paths:
   
   ```
   cols=[arr, arr.element, mp, mp.value, mp.key, id]
   guard threw: ... column 'arr.element' from 'long' to 'timestamp' ...
   guard out arr = 
{"type":"array","items":{"type":"long","logicalType":"timestamp-micros"}}
   guard out mp  = 
{"type":"map","values":{"type":"long","logicalType":"timestamp-micros"}}
   ```
   
   So the behaviour is fine, but nothing pins it and nothing tells a user that 
an array element is addressed as `arr.element` and a map value as `mp.value`. 
Please add those two cases here and one sentence naming the `element` / `value` 
path segments in the config doc.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to