hudi-agent commented on code in PR #19927:
URL: https://github.com/apache/hudi/pull/19927#discussion_r4147639747


##########
pom.xml:
##########
@@ -220,6 +220,12 @@
     <skipUTs>${skipTests}</skipUTs>
     <skipFTs>${skipTests}</skipFTs>
     <skipITs>${skipTests}</skipITs>
+    <!-- Surefire forks per module, expressed per CPU core the way Maven -T 
is, so a 4-core GitHub
+         runner gets two forks and a 2-core Azure agent gets one; each fork 
gets the full argLine
+         heap; override with -Dhudi.surefire.forkCount=1 to serialise a module 
(for example when
+         bisecting a test that depends on shared state), and pass an explicit 
integer on machines
+         with a single core. -->
+    <hudi.surefire.forkCount>0.5C</hudi.surefire.forkCount>

Review Comment:
   🤖 Since this default lives in the root pom, it also applies to local `mvn 
test`. A 12–16 core laptop would get 6–8 forks, each with `-Xmx4g` plus its own 
local Spark. Could the default stay at 1, with the workflows passing 
`-Dhudi.surefire.forkCount=0.5C` (or `2`) explicitly?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-hadoop-common/src/main/java/org/apache/hudi/common/util/ParquetUtils.java:
##########
@@ -350,9 +350,26 @@ public HoodieFileFormat getFormat() {
    */
   @Override
   public List<GenericRecord> readAvroRecords(HoodieStorage storage, 
StoragePath filePath) {
+    // A schema-less read takes the schema from the file; the copy drops any 
read schema or
+    // projection a previous caller left on the shared configuration.
+    Configuration conf = storage.getConf().unwrapCopyAs(Configuration.class);
+    conf.unset("parquet.avro.read.schema");

Review Comment:
   🤖 Reading on a copy makes sense. It might also be worth fixing the source 
you found: `HDFSParquetImporterUtils#buildHoodieRecordsForImport` sets the read 
schema on `jsc.hadoopConfiguration()` after `Job.getInstance` has already 
copied it. The importer never applies that schema, and it leaks to every other 
reader of the shared conf. Setting it on `job.getConfiguration()` would fix 
both.
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/HoodieSparkSqlTestBase.scala:
##########
@@ -107,34 +146,119 @@ class HoodieSparkSqlTestBase extends FunSuite with 
BeforeAndAfterAll {
     new File(sparkWareHouse, tableName).getCanonicalPath
   }
 
+  /**
+   * Holds the read side of [[HoodieSparkSqlTestBase.suiteLock]] for the whole 
suite, beforeAll and afterAll
+   * included, so an [[ExclusiveSuite]] never overlaps any part of another 
suite. ExclusiveSuite takes the
+   * write side around this method; ReentrantReadWriteLock lets a writer also 
acquire the read side.
+   */
+  override def run(testName: Option[String], args: Args): Status = {
+    val readLock = HoodieSparkSqlTestBase.suiteLock.readLock()
+    readLock.lock()
+    try {
+      super.run(testName, args)
+    } finally {
+      readLock.unlock()
+    }
+  }
+
   override protected def test(testName: String, testTags: Tag*)(testFun: => 
Any /* Assertion */)(implicit pos: source.Position): Unit = {
-    super.test(testName, testTags: _*)(
+    super.test(testName, testTags: _*)({
       try {
+        if (sharedSessionEnabled) {
+          bindSuiteSession()
+        }
         testFun
       } finally {
         // The INMEMORY index keeps a JVM-static record-location map; reset it 
after every test so
         // stale keys from an earlier test cannot misroute writes in a later 
one. withRecordType
         // clears it between record-type iterations, but only on success and 
only for tests that use
         // it, so a throwing or non-withRecordType INMEMORY test would 
otherwise leak state here.
-        // Runs before the catalog cleanup so it holds even if a drop throws.
-        HoodieInMemoryHashIndex.clear()
-        val catalog = spark.sessionState.catalog
-        catalog.listDatabases().foreach { db =>
-          catalog.listTables(db).foreach { table =>
-            catalog.dropTable(table, true, true)
-          }
+        // Runs before the catalog cleanup so it holds even if a drop throws. 
In shared mode this is
+        // a no-op, see clearInMemoryIndex.
+        clearInMemoryIndex()
+        dropSuiteTables()
+      }
+    })
+  }
+
+  /**
+   * Per-suite mode: reset the JVM-static INMEMORY index between tests (see 
the note in test()).
+   * Shared mode: the index is keyed per table and dropSuiteTables clears each 
dropped table's
+   * entry, so a global clear would only wipe other suites' tables. A test 
that drops and re-creates
+   * a table at the same path inside one test must clear that path itself
+   * (HoodieInMemoryHashIndex.clear(basePath)); tagLocation's commit-time 
check accepts entries from
+   * an earlier table at the same path.
+   */
+  protected def clearInMemoryIndex(): Unit = {
+    if (!sharedSessionEnabled) {
+      HoodieInMemoryHashIndex.clear()
+    }
+  }
+
+  /**
+   * Shared mode: scalatest runs each suite on its own thread and Hudi reads 
SparkSession.active in a few
+   * places (HoodieCatalog captures it when the session's catalog is first 
built, BaseProcedure per CALL),
+   * so pin this suite's child session to the test thread and fail fast if the 
session's HoodieCatalog was
+   * built against another session.
+   */
+  private def bindSuiteSession(): Unit = {
+    SparkSession.setActiveSession(spark)
+    
spark.sessionState.catalogManager.catalog(CatalogManager.SESSION_CATALOG_NAME) 
match {
+      case hoodieCatalog: HoodieCatalog =>
+        assert(hoodieCatalog.spark eq spark,
+          s"HoodieCatalog of ${getClass.getSimpleName} is bound to another 
SparkSession")
+      case _ =>
+    }
+  }
+
+  /**
+   * Drops the tables a test left behind. Per-suite mode owns the whole 
catalog. Shared mode shares the
+   * external catalog with every other suite in the JVM, so it drops only this 
suite's generateTableName
+   * tables plus any table whose name is not of that form (fixed names and 
temp views); under serial
+   * execution those can only come from the test that just ran. Fixed names 
are renamed in a later step.
+   */
+  private def dropSuiteTables(): Unit = {
+    val catalog = spark.sessionState.catalog
+    catalog.listDatabases().foreach { db =>
+      val tables = catalog.listTables(db)
+      val toDrop = if (sharedSessionEnabled) tables.filter(table => 
ownsTable(table.table)) else tables
+      toDrop.foreach { table =>
+        if (sharedSessionEnabled && !catalog.isTempView(table)) {
+          // Shared mode keeps one index map per table; drop this table's 
entry with the table.
+          Try(catalog.getTableMetadata(table).location.getPath)
+            .foreach(path => HoodieInMemoryHashIndex.clear(path))
         }
+        catalog.dropTable(table, true, true)
       }
-    )
+    }
   }
 
+  /** Shared mode: a table is this suite's if generateTableName produced it, 
or if no suite's generateTableName could have. */
+  private def ownsTable(name: String): Boolean = {
+    name.startsWith(tableNamePrefix) || 
!HoodieSparkSqlTestBase.GENERATED_TABLE_NAME.matcher(name).matches()

Review Comment:
   🤖 Names derived from `generateTableName`, like `${tableName}_ro`/`_rt` 
(TestDropTable), `${tableName}_1`/`_loc` (TestAlterTable) or 
`${generateTableName}_timestamp_type` (TestInsertTable2), don't fully match 
`GENERATED_TABLE_NAME`. So when suites run concurrently, every other suite's 
per-test cleanup treats them as its own and drops them with purge. Could the 
foreign check use `lookingAt()` (prefix match) instead of `matches()`?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/HoodieSparkSqlTestBase.scala:
##########
@@ -107,34 +146,119 @@ class HoodieSparkSqlTestBase extends FunSuite with 
BeforeAndAfterAll {
     new File(sparkWareHouse, tableName).getCanonicalPath
   }
 
+  /**
+   * Holds the read side of [[HoodieSparkSqlTestBase.suiteLock]] for the whole 
suite, beforeAll and afterAll
+   * included, so an [[ExclusiveSuite]] never overlaps any part of another 
suite. ExclusiveSuite takes the
+   * write side around this method; ReentrantReadWriteLock lets a writer also 
acquire the read side.
+   */
+  override def run(testName: Option[String], args: Args): Status = {
+    val readLock = HoodieSparkSqlTestBase.suiteLock.readLock()

Review Comment:
   🤖 nit: `run`, `test`, `bindSuiteSession`, `dropSuiteTables`, and `ownsTable` 
are all fairly small and interleaved with unrelated helpers (e.g. 
`generateTableName`, `afterAll`) — might be worth grouping the shared-session 
machinery together (or into a small private trait) so the concurrency-control 
logic reads as one cohesive unit rather than being spread through the class.
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieInMemoryHashIndex.java:
##########
@@ -126,10 +139,19 @@ public boolean isImplicitWithStorage() {
     return false;
   }
 
+  /**
+   * Clears the locations of every table in this JVM.
+   */
   @VisibleForTesting
   public static void clear() {
-    if (recordLocationMap != null) {
-      recordLocationMap.clear();
-    }
+    RECORD_LOCATIONS_PER_TABLE.clear();
+  }
+
+  /**
+   * Clears the locations of a single table.
+   */
+  @VisibleForTesting
+  public static void clear(String basePath) {
+    RECORD_LOCATIONS_PER_TABLE.remove(tableKey(new StoragePath(basePath)));

Review Comment:
   🤖 nit: the new `clear(String basePath)` overload takes a raw path string 
while `tableKey` operates on `StoragePath` everywhere else — could this take 
`StoragePath` too, for consistency and to avoid re-parsing a string at each 
call site?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-utilities/src/test/java/org/apache/hudi/utilities/deltastreamer/TestHoodieDeltaStreamerSchemaAndTypes.java:
##########
@@ -0,0 +1,1545 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.hudi.utilities.deltastreamer;
+
+import org.apache.hudi.DataSourceWriteOptions;
+import org.apache.hudi.DefaultSparkRecordMerger;
+import org.apache.hudi.HoodieSparkUtils;
+import org.apache.hudi.common.config.HoodieCommonConfig;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.config.HoodieStorageConfig;
+import org.apache.hudi.common.config.RecordMergeMode;
+import org.apache.hudi.common.config.TypedProperties;
+import org.apache.hudi.common.engine.HoodieLocalEngineContext;
+import org.apache.hudi.common.model.DefaultHoodieRecordPayload;
+import org.apache.hudi.common.model.HoodieAvroRecordMerger;
+import org.apache.hudi.common.model.HoodieBaseFile;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType;
+import org.apache.hudi.common.model.HoodieRecordMerger;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.PartialUpdateAvroPayload;
+import org.apache.hudi.common.model.WriteOperationType;
+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;
+import org.apache.hudi.common.table.TableSchemaResolver;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.table.timeline.InstantComparison;
+import org.apache.hudi.common.table.view.FileSystemViewManager;
+import org.apache.hudi.common.table.view.HoodieTableFileSystemView;
+import org.apache.hudi.common.testutils.HoodieTestDataGenerator;
+import org.apache.hudi.common.testutils.HoodieTestUtils;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieCleanConfig;
+import org.apache.hudi.config.HoodieCompactionConfig;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.exception.SchemaCompatibilityException;
+import org.apache.hudi.hadoop.fs.HadoopFSUtils;
+import org.apache.hudi.keygen.CustomKeyGenerator;
+import org.apache.hudi.utilities.UtilHelpers;
+import org.apache.hudi.utilities.ingestion.HoodieIngestionException;
+import org.apache.hudi.utilities.schema.FilebasedSchemaProvider;
+import org.apache.hudi.utilities.sources.JsonKafkaSource;
+import org.apache.hudi.utilities.sources.ParquetDFSSource;
+import org.apache.hudi.utilities.sources.TestDataSource;
+import org.apache.hudi.utilities.sources.TestParquetDFSSourceEmptyBatch;
+import 
org.apache.hudi.utilities.sources.helpers.TestMercifulJsonToRowConverterBase;
+import org.apache.hudi.utilities.streamer.HoodieStreamer;
+import org.apache.hudi.utilities.testutils.UtilitiesTestBase;
+import org.apache.hudi.utilities.testutils.sources.AbstractBaseTestSource;
+import org.apache.hudi.utilities.transform.SqlQueryBasedTransformer;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.avro.LogicalType;
+import org.apache.avro.LogicalTypes;
+import org.apache.avro.Schema;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static 
org.apache.hudi.common.table.timeline.InstantComparison.GREATER_THAN;
+import static org.apache.hudi.common.util.StringUtils.EMPTY_STRING;
+import static org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient;
+import static 
org.apache.hudi.utilities.deltastreamer.TestHoodieDeltaStreamer.DummyAvroPayload;
+import static 
org.apache.hudi.utilities.deltastreamer.TestHoodieDeltaStreamer.NullValueSchemaProvider;
+import static 
org.apache.hudi.utilities.deltastreamer.TestHoodieDeltaStreamer.TestFileBasedSchemaProviderNullTargetSchema;
+import static 
org.apache.hudi.utilities.deltastreamer.TestHoodieDeltaStreamer.TestIdentityTransformer;
+import static 
org.apache.hudi.utilities.deltastreamer.TestHoodieDeltaStreamer.TripsWithEvolvedOptionalFieldTransformer;
+import static 
org.apache.hudi.utilities.deltastreamer.TestHoodieDeltaStreamer.assertCheckpointVersion;
+import static 
org.apache.hudi.utilities.deltastreamer.TestHoodieDeltaStreamer.prepareJsonKafkaDFSFiles;
+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;
+
+/**
+ * Schema and type coverage for {@link HoodieDeltaStreamer}: schema evolution, 
logical types and the repair of
+ * corrupt logical-type metadata, payload classes, ordering fields and 
de-duplication.
+ * Shared helpers and the transformer, key generator and payload classes these 
tests name live in
+ * {@link TestHoodieDeltaStreamer}.
+ */
+@Slf4j
+public class TestHoodieDeltaStreamerSchemaAndTypes extends 
HoodieDeltaStreamerTestBase {
+
+  // Kept per class rather than hoisted to the base, because the other base 
subclasses deliberately do not increment testNum.
+  @AfterEach
+  public void perTestAfterEach() {
+    testNum++;
+  }
+
+  // Per-field verdict for the corrupt logical-repair fixtures: relabel 
ts_millis to millis and
+  // attach the local-timestamp logical types that 0.x dropped. ts_micros is 
already micros.
+  private static final String LOGICAL_REPAIR_TS_OVERRIDES =
+      
"ts_millis:timestamp-millis,local_ts_millis:local-timestamp-millis,local_ts_micros:local-timestamp-micros";
+
+  /**
+   * args for schema evolution test.
+   *
+   * @return
+   */
+  private static Stream<Arguments> schemaEvolArgs() {
+    return Stream.of(
+        Arguments.of(DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL(), true, 
HoodieRecordType.AVRO),
+        Arguments.of(DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL(), false, 
HoodieRecordType.AVRO),
+        Arguments.of(DataSourceWriteOptions.MOR_TABLE_TYPE_OPT_VAL(), true, 
HoodieRecordType.AVRO),
+        Arguments.of(DataSourceWriteOptions.MOR_TABLE_TYPE_OPT_VAL(), false, 
HoodieRecordType.AVRO),
+
+        Arguments.of(DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL(), true, 
HoodieRecordType.SPARK),
+        Arguments.of(DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL(), false, 
HoodieRecordType.SPARK),
+        Arguments.of(DataSourceWriteOptions.MOR_TABLE_TYPE_OPT_VAL(), true, 
HoodieRecordType.SPARK),
+        Arguments.of(DataSourceWriteOptions.MOR_TABLE_TYPE_OPT_VAL(), false, 
HoodieRecordType.SPARK));
+  }
+
+  @ParameterizedTest
+  @EnumSource(value = HoodieTableVersion.class, names = {"SIX", "EIGHT"})
+  public void testPartitionKeyFieldsBasedOnVersion(HoodieTableVersion version) 
throws IOException {
+    String tablePath = basePath + "/partition_key_fields_meta_client" + 
version.versionCode();
+    HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(tablePath, 
WriteOperationType.INSERT);
+    cfg.configs.add(HoodieWriteConfig.WRITE_TABLE_VERSION.key() + "=" + 
version.versionCode());
+    cfg.configs.add(HoodieWriteConfig.KEYGENERATOR_CLASS_NAME.key() + "=" + 
CustomKeyGenerator.class.getName());
+    
cfg.configs.add("hoodie.datasource.write.partitionpath.field=partition_path:simple");
+    HoodieDeltaStreamer deltaStreamer = new HoodieDeltaStreamer(cfg, jsc);
+    deltaStreamer.getIngestionService().ingestOnce();
+    HoodieTableMetaClient metaClient = 
HoodieTestUtils.createMetaClient(context, tablePath);
+    String expectedPartitionFields = version.equals(HoodieTableVersion.SIX) ? 
"partition_path" : "partition_path:simple";
+    assertEquals(expectedPartitionFields, 
metaClient.getTableConfig().getString(HoodieTableConfig.PARTITION_FIELDS));
+    deltaStreamer.shutdownGracefully();
+  }
+
+  // TODO add tests w/ disabled reconciliation
+  @ParameterizedTest
+  @MethodSource("schemaEvolArgs")
+  public void testSchemaEvolution(String tableType, boolean 
useUserProvidedSchema, HoodieRecordType recordType) throws Exception {
+    String tableBasePath = basePath + "/test_table_schema_evolution" + 
tableType + "_" + useUserProvidedSchema;
+    defaultSchemaProviderClassName = FilebasedSchemaProvider.class.getName();
+    // Insert data produced with Schema A, pass Schema A
+    HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(tableBasePath, 
WriteOperationType.INSERT, 
Collections.singletonList(TestIdentityTransformer.class.getName()),
+        PROPS_FILENAME_TEST_SOURCE, false, true, false, null, tableType);
+    addRecordMerger(recordType, cfg.configs);
+    cfg.payloadClassName = DefaultHoodieRecordPayload.class.getName();
+    cfg.recordMergeStrategyId = 
HoodieRecordMerger.EVENT_TIME_BASED_MERGE_STRATEGY_UUID;
+    cfg.recordMergeMode = RecordMergeMode.EVENT_TIME_ORDERING;
+    cfg.configs.add("hoodie.streamer.schemaprovider.source.schema.file=" + 
basePath + "/source.avsc");
+    cfg.configs.add("hoodie.streamer.schemaprovider.target.schema.file=" + 
basePath + "/source.avsc");
+    cfg.configs.add(DataSourceWriteOptions.RECONCILE_SCHEMA().key() + "=true");
+
+    syncOnce(cfg);
+    assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, 
tableBasePath));
+
+    assertRecordCount(1000, tableBasePath, sqlContext);
+    TestHelpers.assertCommitMetadata("00000", tableBasePath, 1);
+
+    // Upsert data produced with Schema B, pass Schema B
+    cfg = TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT, 
Collections.singletonList(TripsWithEvolvedOptionalFieldTransformer.class.getName()),
+        PROPS_FILENAME_TEST_SOURCE, false, true, false, null, tableType);
+    addRecordMerger(recordType, cfg.configs);
+    cfg.configs.add("hoodie.streamer.schemaprovider.source.schema.file=" + 
basePath + "/source.avsc");
+    cfg.configs.add("hoodie.streamer.schemaprovider.target.schema.file=" + 
basePath + "/source_evolved.avsc");
+    cfg.configs.add(DataSourceWriteOptions.RECONCILE_SCHEMA().key() + "=true");
+    syncOnce(cfg);
+    assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, 
tableBasePath));
+    // out of 1000 new records, 500 are inserts, 450 are updates and 50 are 
deletes.
+    assertRecordCount(1450, tableBasePath, sqlContext);
+    TestHelpers.assertCommitMetadata("00001", tableBasePath, 2);
+    List<Row> counts = countsPerCommit(tableBasePath, sqlContext);
+    assertEquals(1450, counts.stream().mapToLong(entry -> 
entry.getLong(1)).sum());
+
+    
sqlContext.read().format("org.apache.hudi").load(tableBasePath).createOrReplaceTempView("tmp_trips");
+    long recordCount =
+        sqlContext.sparkSession().sql("select * from tmp_trips where 
evoluted_optional_union_field is not NULL").count();
+    assertEquals(950, recordCount);
+
+    // Upsert data produced with Schema A, pass Schema B
+    if (!useUserProvidedSchema) {
+      defaultSchemaProviderClassName = 
TestFileBasedSchemaProviderNullTargetSchema.class.getName();
+    }
+    cfg = TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT, 
Collections.singletonList(TestIdentityTransformer.class.getName()),
+        PROPS_FILENAME_TEST_SOURCE, false, true, false, null, tableType);
+    addRecordMerger(recordType, cfg.configs);
+    cfg.configs.add("hoodie.streamer.schemaprovider.source.schema.file=" + 
basePath + "/source.avsc");
+    if (useUserProvidedSchema) {
+      cfg.configs.add("hoodie.streamer.schemaprovider.target.schema.file=" + 
basePath + "/source_evolved.avsc");
+    }
+    cfg.configs.add(DataSourceWriteOptions.RECONCILE_SCHEMA().key() + "=true");
+    syncOnce(cfg);
+    // again, 1000 new records, 500 are inserts, 450 are updates and 50 are 
deletes.
+    assertRecordCount(1900, tableBasePath, sqlContext);
+    TestHelpers.assertCommitMetadata("00002", tableBasePath, 3);
+    counts = countsPerCommit(tableBasePath, sqlContext);
+    assertEquals(1900, counts.stream().mapToLong(entry -> 
entry.getLong(1)).sum());
+
+    TableSchemaResolver tableSchemaResolver = new TableSchemaResolver(
+        HoodieTestUtils.createMetaClient(storage, tableBasePath));
+    HoodieSchema tableSchema = tableSchemaResolver.getTableSchema(false);
+    assertNotNull(tableSchema);
+
+    HoodieSchema expectedSchema = HoodieSchema.parse(fs.open(new Path(basePath 
+ "/source_evolved.avsc")));
+    assertEquals(expectedSchema, tableSchema);
+
+    // clean up and reinit
+    UtilitiesTestBase.Helpers.deleteFileFromDfs(fs, tableBasePath);
+    UtilitiesTestBase.Helpers.deleteFileFromDfs(
+        HadoopFSUtils.getFs(cfg.targetBasePath, jsc.hadoopConfiguration()),
+        basePath + "/" + PROPS_FILENAME_TEST_SOURCE);
+    writeCommonPropsToFile(storage, basePath);
+    defaultSchemaProviderClassName = FilebasedSchemaProvider.class.getName();
+  }
+
+  @Test
+  public void testTimestampMillis() throws Exception {
+    String tableBasePath = basePath + "/testTimestampMillis";
+    defaultSchemaProviderClassName = FilebasedSchemaProvider.class.getName();
+    // Insert data produced with Schema A, pass Schema A
+    HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(tableBasePath, 
WriteOperationType.INSERT, 
Collections.singletonList(TestIdentityTransformer.class.getName()),
+        PROPS_FILENAME_TEST_SOURCE, false, true, false, null, 
HoodieTableType.MERGE_ON_READ.name());
+    cfg.payloadClassName = DefaultHoodieRecordPayload.class.getName();
+    cfg.recordMergeStrategyId = 
HoodieRecordMerger.EVENT_TIME_BASED_MERGE_STRATEGY_UUID;
+    cfg.recordMergeMode = RecordMergeMode.EVENT_TIME_ORDERING;
+    cfg.configs.add("hoodie.streamer.schemaprovider.source.schema.file=" + 
basePath + "/source-timestamp-millis.avsc");
+    cfg.configs.add("hoodie.streamer.schemaprovider.target.schema.file=" + 
basePath + "/source-timestamp-millis.avsc");
+    cfg.configs.add(String.format("%s=%s", 
HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key(), "0"));
+    cfg.configs.add("hoodie.datasource.write.row.writer.enable=false");
+
+
+    syncOnce(cfg);
+    assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, 
tableBasePath));
+    assertRecordCount(1000, tableBasePath, sqlContext);
+    TestHelpers.assertCommitMetadata("00000", tableBasePath, 1);
+    TableSchemaResolver tableSchemaResolver = new TableSchemaResolver(
+        HoodieTestUtils.createMetaClient(storage, tableBasePath));
+    HoodieSchema tableSchema = tableSchemaResolver.getTableSchema(false);
+    Option<HoodieSchemaField> currentTsFieldOpt = 
tableSchema.getField("current_ts");
+    assertTrue(currentTsFieldOpt.isPresent());
+    HoodieSchema.Timestamp currentTsSchema = (HoodieSchema.Timestamp) 
currentTsFieldOpt.get().schema();
+    assertEquals(HoodieSchemaType.TIMESTAMP, currentTsSchema.getType());
+    assertEquals(TimePrecision.MILLIS, currentTsSchema.getPrecision());
+    assertEquals(1000, 
sqlContext.read().options(hudiOpts).format("org.apache.hudi").load(tableBasePath).filter("current_ts
 > '1980-01-01'").count());
+
+    cfg = TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT, 
Collections.singletonList(TestIdentityTransformer.class.getName()),
+        PROPS_FILENAME_TEST_SOURCE, false, true, false, null, 
HoodieTableType.MERGE_ON_READ.name());
+    cfg.payloadClassName = DefaultHoodieRecordPayload.class.getName();
+    cfg.recordMergeStrategyId = 
HoodieRecordMerger.EVENT_TIME_BASED_MERGE_STRATEGY_UUID;
+    cfg.recordMergeMode = RecordMergeMode.EVENT_TIME_ORDERING;
+    cfg.configs.add("hoodie.streamer.schemaprovider.source.schema.file=" + 
basePath + "/source-timestamp-millis.avsc");
+    cfg.configs.add("hoodie.streamer.schemaprovider.target.schema.file=" + 
basePath + "/source-timestamp-millis.avsc");
+    cfg.configs.add(String.format("%s=%s", 
HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key(), "0"));
+    cfg.configs.add("hoodie.datasource.write.row.writer.enable=false");
+
+    syncOnce(cfg);
+    assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, 
tableBasePath));
+    assertRecordCount(1450, tableBasePath, sqlContext);
+    TestHelpers.assertCommitMetadata("00001", tableBasePath, 2);
+    tableSchemaResolver = new TableSchemaResolver(
+        HoodieTestUtils.createMetaClient(storage, tableBasePath));
+    tableSchema = tableSchemaResolver.getTableSchema(false);
+    currentTsFieldOpt = tableSchema.getField("current_ts");
+    assertTrue(currentTsFieldOpt.isPresent());
+    currentTsSchema = (HoodieSchema.Timestamp) 
currentTsFieldOpt.get().schema();
+    assertEquals(HoodieSchemaType.TIMESTAMP, currentTsSchema.getType());
+    assertEquals(TimePrecision.MILLIS, currentTsSchema.getPrecision());
+    sqlContext.clearCache();
+    assertEquals(1450, 
sqlContext.read().options(hudiOpts).format("org.apache.hudi").load(tableBasePath).filter("current_ts
 > '1980-01-01'").count());
+    assertEquals(1450, 
sqlContext.read().options(hudiOpts).format("org.apache.hudi").load(tableBasePath).filter("current_ts
 < '2080-01-01'").count());
+    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 {
+      String tableBasePath = basePath + "/testTimestampMillis";
+      defaultSchemaProviderClassName = 
TestHoodieDeltaStreamerSchemaEvolutionBase.TestSchemaProvider.class.getName();
+
+      if (HoodieSparkUtils.isSpark3_3()) {
+        
TestHoodieDeltaStreamerSchemaEvolutionBase.TestSchemaProvider.sourceSchema = 
HoodieTestDataGenerator.HOODIE_SCHEMA_TRIP_LOGICAL_TYPES_SCHEMA_NO_LTS;
+        
TestHoodieDeltaStreamerSchemaEvolutionBase.TestSchemaProvider.targetSchema = 
HoodieTestDataGenerator.HOODIE_SCHEMA_TRIP_LOGICAL_TYPES_SCHEMA_NO_LTS;
+        AbstractBaseTestSource.schemaStr = 
HoodieTestDataGenerator.TRIP_LOGICAL_TYPES_SCHEMA_NO_LTS;
+      } else {
+        
TestHoodieDeltaStreamerSchemaEvolutionBase.TestSchemaProvider.sourceSchema = 
HoodieTestDataGenerator.HOODIE_SCHEMA_TRIP_LOGICAL_TYPES_SCHEMA;
+        
TestHoodieDeltaStreamerSchemaEvolutionBase.TestSchemaProvider.targetSchema = 
HoodieTestDataGenerator.HOODIE_SCHEMA_TRIP_LOGICAL_TYPES_SCHEMA;
+        AbstractBaseTestSource.schemaStr = 
HoodieTestDataGenerator.TRIP_LOGICAL_TYPES_SCHEMA;
+      }
+
+      // Insert data produced with Schema A, pass Schema A
+      HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(tableBasePath, 
WriteOperationType.INSERT, 
Collections.singletonList(TestIdentityTransformer.class.getName()),
+          PROPS_FILENAME_TEST_SOURCE, false, true, false, null, 
HoodieTableType.MERGE_ON_READ.name());
+      cfg.payloadClassName = DefaultHoodieRecordPayload.class.getName();
+      cfg.recordMergeStrategyId = 
HoodieRecordMerger.EVENT_TIME_BASED_MERGE_STRATEGY_UUID;
+      cfg.recordMergeMode = RecordMergeMode.EVENT_TIME_ORDERING;
+      cfg.configs.add(String.format("%s=%s", 
HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key(), "0"));
+      cfg.configs.add("hoodie.datasource.write.row.writer.enable=false");
+
+      syncOnce(cfg);
+      assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, 
tableBasePath));
+      assertRecordCount(1000, tableBasePath, sqlContext);
+      TestHelpers.assertCommitMetadata("00000", tableBasePath, 1);
+      TableSchemaResolver tableSchemaResolver = new TableSchemaResolver(
+          HoodieTestUtils.createMetaClient(storage, tableBasePath));
+      HoodieSchema tableSchema = tableSchemaResolver.getTableSchema(false);
+      Map<String, String> hudiOpts = new HashMap<>();
+      hudiOpts.put("hoodie.datasource.write.recordkey.field", "id");
+      logicalAssertions(tableSchema, tableBasePath, hudiOpts, 
HoodieTableVersion.current().versionCode());
+
+
+      cfg = TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT, 
Collections.singletonList(TestIdentityTransformer.class.getName()),
+          PROPS_FILENAME_TEST_SOURCE, false, true, false, null, 
HoodieTableType.MERGE_ON_READ.name());
+      cfg.payloadClassName = DefaultHoodieRecordPayload.class.getName();
+      cfg.recordMergeStrategyId = 
HoodieRecordMerger.EVENT_TIME_BASED_MERGE_STRATEGY_UUID;
+      cfg.recordMergeMode = RecordMergeMode.EVENT_TIME_ORDERING;
+      cfg.configs.add(String.format("%s=%s", 
HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key(), "0"));
+      cfg.configs.add("hoodie.datasource.write.row.writer.enable=false");
+
+      syncOnce(cfg);
+      assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, 
tableBasePath));
+      assertRecordCount(1450, tableBasePath, sqlContext);
+      TestHelpers.assertCommitMetadata("00001", tableBasePath, 2);
+      tableSchemaResolver = new TableSchemaResolver(
+          HoodieTestUtils.createMetaClient(storage, tableBasePath));
+      tableSchema = tableSchemaResolver.getTableSchema(false);
+      logicalAssertions(tableSchema, tableBasePath, hudiOpts, 
HoodieTableVersion.current().versionCode());
+    } finally {
+      defaultSchemaProviderClassName = FilebasedSchemaProvider.class.getName();
+      AbstractBaseTestSource.schemaStr = 
HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA;
+    }
+  }
+
+  /**
+   * Arguments for testLogicalTypesWithJsonSource.
+   * Parameters: hasTransformer, orderingField, recordType, tableType
+   */
+  private static Stream<Arguments> logicalTypesWithJsonSourceArgs() {
+    return Stream.of(
+        // Test with timestamp ordering field (long type)
+        Arguments.of(true, "timestamp", HoodieRecordType.AVRO, 
HoodieTableType.MERGE_ON_READ),
+        Arguments.of(false, "timestamp", HoodieRecordType.AVRO, 
HoodieTableType.MERGE_ON_READ),
+        Arguments.of(true, "timestamp", HoodieRecordType.SPARK, 
HoodieTableType.MERGE_ON_READ),
+        Arguments.of(false, "timestamp", HoodieRecordType.SPARK, 
HoodieTableType.MERGE_ON_READ),
+        Arguments.of(true, "timestamp", HoodieRecordType.AVRO, 
HoodieTableType.COPY_ON_WRITE),
+        Arguments.of(false, "timestamp", HoodieRecordType.AVRO, 
HoodieTableType.COPY_ON_WRITE),
+        Arguments.of(true, "timestamp", HoodieRecordType.SPARK, 
HoodieTableType.COPY_ON_WRITE),
+        Arguments.of(false, "timestamp", HoodieRecordType.SPARK, 
HoodieTableType.COPY_ON_WRITE),
+        // Test with rider ordering field (string type)
+        Arguments.of(true, "rider", HoodieRecordType.AVRO, 
HoodieTableType.MERGE_ON_READ),
+        Arguments.of(false, "rider", HoodieRecordType.AVRO, 
HoodieTableType.MERGE_ON_READ),
+        Arguments.of(true, "rider", HoodieRecordType.SPARK, 
HoodieTableType.MERGE_ON_READ),
+        Arguments.of(false, "rider", HoodieRecordType.SPARK, 
HoodieTableType.MERGE_ON_READ),
+        Arguments.of(true, "rider", HoodieRecordType.AVRO, 
HoodieTableType.COPY_ON_WRITE),
+        Arguments.of(false, "rider", HoodieRecordType.AVRO, 
HoodieTableType.COPY_ON_WRITE),
+        Arguments.of(true, "rider", HoodieRecordType.SPARK, 
HoodieTableType.COPY_ON_WRITE),
+        Arguments.of(false, "rider", HoodieRecordType.SPARK, 
HoodieTableType.COPY_ON_WRITE));
+  }
+
+  @ParameterizedTest
+  @MethodSource("logicalTypesWithJsonSourceArgs")
+  void testLogicalTypesWithJsonSource(boolean hasTransformer, String 
orderingField,
+                                      HoodieRecordType recordType, 
HoodieTableType tableType) throws Exception {
+    // Fix a seed so we can generate repeated rows for updates
+    final long seed = 123L;
+    final int numPartitions = 2;
+
+    try {
+      //use v6 schema because decimal parsing iso 8859-1 support not available 
currently
+      String schemaStr;
+      if (HoodieSparkUtils.isSpark3_3()) {
+        
TestHoodieDeltaStreamerSchemaEvolutionBase.TestSchemaProvider.sourceSchema =
+            
HoodieTestDataGenerator.HOODIE_SCHEMA_TRIP_LOGICAL_TYPES_SCHEMA_NO_LTS_V6;
+        
TestHoodieDeltaStreamerSchemaEvolutionBase.TestSchemaProvider.targetSchema =
+            
HoodieTestDataGenerator.HOODIE_SCHEMA_TRIP_LOGICAL_TYPES_SCHEMA_NO_LTS_V6;
+        schemaStr = 
HoodieTestDataGenerator.TRIP_LOGICAL_TYPES_SCHEMA_NO_LTS_V6;
+      } else {
+        
TestHoodieDeltaStreamerSchemaEvolutionBase.TestSchemaProvider.sourceSchema =
+            HoodieTestDataGenerator.HOODIE_SCHEMA_TRIP_LOGICAL_TYPES_SCHEMA_V6;
+        
TestHoodieDeltaStreamerSchemaEvolutionBase.TestSchemaProvider.targetSchema =
+            HoodieTestDataGenerator.HOODIE_SCHEMA_TRIP_LOGICAL_TYPES_SCHEMA_V6;
+        schemaStr = HoodieTestDataGenerator.TRIP_LOGICAL_TYPES_SCHEMA_V6;
+      }
+      defaultSchemaProviderClassName =
+          
TestHoodieDeltaStreamerSchemaEvolutionBase.TestSchemaProvider.class.getName();
+      String tableBasePath = basePath + "testTimestampMillis_" + orderingField 
+ "_" + recordType + "_" + tableType.name();
+      prepareJsonKafkaDFSSource(
+          PROPS_FILENAME_TEST_JSON_KAFKA, "earliest", topicName);
+
+      // Insert data produced with Schema A, pass Schema A
+      prepareJsonKafkaDFSFiles(1000, true, topicName, numPartitions, 
schemaStr, seed);
+      HoodieDeltaStreamer.Config cfg = getConfigForLogicalTypesWithJsonSource(
+          tableBasePath, WriteOperationType.INSERT, hasTransformer, 
orderingField, recordType, tableType);
+      syncOnce(cfg);
+      // Validate.
+      assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, 
tableBasePath));
+      assertRecordCount(1000, tableBasePath, sqlContext);
+      TestHelpers.assertCommitMetadata(topicName + ",0:500,1:500", 
tableBasePath, 1);
+      TableSchemaResolver tableSchemaResolver = new TableSchemaResolver(
+          HoodieTestUtils.createMetaClient(storage, tableBasePath));
+      HoodieSchema tableSchema = tableSchemaResolver.getTableSchema(false);
+      Map<String, String> hudiOpts = new HashMap<>();
+      hudiOpts.put("hoodie.datasource.write.recordkey.field", "id");
+      logicalAssertions(tableSchema, tableBasePath, hudiOpts, 
HoodieTableVersion.EIGHT.versionCode());
+
+      // Update data, since we are using the same seed, 1000 rows will be 
updated, and 500 new rows will be inserted
+      prepareJsonKafkaDFSFiles(1500, false, topicName, numPartitions, 
schemaStr, seed);
+      cfg = getConfigForLogicalTypesWithJsonSource(
+          tableBasePath, WriteOperationType.UPSERT, hasTransformer, 
orderingField, recordType, tableType);
+      syncOnce(cfg);
+      // Validate.
+      assertCheckpointVersion(HoodieTestUtils.createMetaClient(storage, 
tableBasePath));
+      assertRecordCount(1500, tableBasePath, sqlContext);
+      TestHelpers.assertCommitMetadata(topicName + ",0:1250,1:1250", 
tableBasePath, 2);
+      tableSchemaResolver = new TableSchemaResolver(
+          HoodieTestUtils.createMetaClient(storage, tableBasePath));
+      tableSchema = tableSchemaResolver.getTableSchema(false);
+      logicalAssertions(tableSchema, tableBasePath, hudiOpts, 
HoodieTableVersion.EIGHT.versionCode());
+    } finally {
+      defaultSchemaProviderClassName = FilebasedSchemaProvider.class.getName();
+    }
+  }
+
+  private static HoodieDeltaStreamer.Config 
getConfigForLogicalTypesWithJsonSource(
+      String tableBasePath, WriteOperationType operationType, boolean 
hasTransformer,
+      String orderingField, HoodieRecordType recordType, HoodieTableType 
tableType) {
+    List<String> transformerClassNames;
+    if (hasTransformer) {
+      transformerClassNames = 
Collections.singletonList(TestIdentityTransformer.class.getName());
+    } else {
+      transformerClassNames = Collections.emptyList();
+    }
+    HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(
+        tableBasePath, operationType, JsonKafkaSource.class.getName(),
+        transformerClassNames,
+        PROPS_FILENAME_TEST_JSON_KAFKA,
+        false, true, 100000, false, null,
+        tableType.name(),
+        orderingField, null);
+    cfg.payloadClassName = DefaultHoodieRecordPayload.class.getName();
+    cfg.recordMergeStrategyId = 
HoodieRecordMerger.EVENT_TIME_BASED_MERGE_STRATEGY_UUID;
+    cfg.recordMergeMode = RecordMergeMode.EVENT_TIME_ORDERING;
+    cfg.configs.add(String.format("%s=%s", 
HoodieCompactionConfig.PARQUET_SMALL_FILE_LIMIT.key(), "0"));
+    cfg.configs.add("hoodie.datasource.write.row.writer.enable=false");
+    
cfg.configs.add("hoodie.streamer.source.sanitize.invalid.schema.field.names=true");
+    // Configure record merger based on record type
+    if (recordType == HoodieRecordType.SPARK) {
+      cfg.configs.add(String.format("%s=%s", 
HoodieWriteConfig.RECORD_MERGE_IMPL_CLASSES.key(),
+          DefaultSparkRecordMerger.class.getName()));
+      cfg.configs.add(String.format("%s=%s", 
HoodieStorageConfig.LOGFILE_DATA_BLOCK_FORMAT.key(), "parquet"));
+    } else {
+      cfg.configs.add(String.format("%s=%s", 
HoodieWriteConfig.RECORD_MERGE_IMPL_CLASSES.key(),
+          HoodieAvroRecordMerger.class.getName()));
+      cfg.configs.add(String.format("%s=%s", 
HoodieStorageConfig.LOGFILE_DATA_BLOCK_FORMAT.key(), "avro"));
+    }
+    return cfg;
+  }
+
+  @ParameterizedTest
+  @EnumSource(value = HoodieTableVersion.class, names = {"SIX", "EIGHT"})
+  public void testBackwardsCompatibility(HoodieTableVersion version) throws 
Exception {
+    TestMercifulJsonToRowConverterBase.timestampNTZCompatibility(() -> {
+      String dirName = "colstats-upgrade-test-v" + version.versionCode();
+      String dataPath = basePath + "/" + dirName;
+      java.nio.file.Path zipOutput = Paths.get(new URI(dataPath));
+      HoodieTestUtils.extractZipToDirectory("col-stats/" + dirName + ".zip", 
zipOutput, getClass());
+      String tableBasePath = 
zipOutput.resolve("trips_logical_types_json").toString();
+
+      TableSchemaResolver tableSchemaResolver = new TableSchemaResolver(
+          HoodieTestUtils.createMetaClient(storage, tableBasePath));
+      HoodieSchema tableSchema = tableSchemaResolver.getTableSchema(false);
+      Map<String, String> hudiOpts = new HashMap<>();
+      hudiOpts.put("hoodie.datasource.write.recordkey.field", "id");
+      logicalAssertions(tableSchema, tableBasePath, hudiOpts, 
version.versionCode());
+
+      HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(tableBasePath, 
WriteOperationType.UPSERT, Collections.emptyList(),
+          "placeholder", false, true, false, null, 
HoodieTableType.MERGE_ON_READ.name());
+      cfg.propsFilePath = zipOutput + "/hudi.properties";
+      cfg.schemaProviderClassName = 
"org.apache.hudi.utilities.schema.FilebasedSchemaProvider";
+      cfg.sourceOrderingFields = "timestamp";
+      cfg.sourceClassName = "org.apache.hudi.utilities.sources.JsonDFSSource";
+      cfg.targetTableName = "trips_logical_types_json";
+      cfg.configs.add("hoodie.streamer.source.dfs.root=" + zipOutput + 
"/data/data_6/");
+      cfg.configs.add(String.format(("%s=%s"), 
HoodieWriteConfig.WRITE_TABLE_VERSION.key(), version.versionCode()));
+      cfg.configs.add(String.format(("%s=%s"), 
HoodieCompactionConfig.INLINE_COMPACT_NUM_DELTA_COMMITS.key(), "100"));
+      String schemaPath = zipOutput + "/schema.avsc";
+      cfg.configs.add(String.format(("%s=%s"), 
"hoodie.streamer.schemaprovider.source.schema.file", schemaPath));
+      cfg.configs.add(String.format(("%s=%s"), 
"hoodie.streamer.schemaprovider.target.schema.file", schemaPath));
+      // The v6/v8 col-stats fixture reuses the same trips_logical_types_json 
corrupt schema as
+      // the logical-repair tests — 0.x collapsed ts_millis to 
timestamp-micros and dropped the
+      // local-timestamp logical types entirely. Provide the same explicit 
verdict so the guard
+      // in HoodieSchemaUtils.deduceWriterSchema authorizes the repair rather 
than rejecting the
+      // unverified precision change.
+      cfg.configs.add(String.format(("%s=%s"),
+          HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key(), 
LOGICAL_REPAIR_TS_OVERRIDES));
+      cfg.forceDisableCompaction = true;
+      cfg.sourceLimit = 100_000;
+      cfg.ignoreCheckpoint = "12345";
+      syncOnce(cfg);
+      logicalAssertions(tableSchema, tableBasePath, hudiOpts, 
version.versionCode());
+    });
+  }
+
+  private void logicalAssertions(HoodieSchema tableSchema, String 
tableBasePath, Map<String, String> hudiOpts, int tableVersion) {
+    if (tableVersion > 8) {
+      Option<HoodieSchemaField> tsMillisFieldOpt = 
tableSchema.getField("ts_millis");
+      assertTrue(tsMillisFieldOpt.isPresent());
+      HoodieSchema.Timestamp tsMillisFieldSchema = (HoodieSchema.Timestamp) 
tsMillisFieldOpt.get().schema();
+      assertEquals(HoodieSchemaType.TIMESTAMP, tsMillisFieldSchema.getType());
+      assertEquals(TimePrecision.MILLIS, tsMillisFieldSchema.getPrecision());
+      assertTrue(tsMillisFieldSchema.isUtcAdjusted());
+    }
+    Option<HoodieSchemaField> tsMicrosFieldOpt = 
tableSchema.getField("ts_micros");
+    assertTrue(tsMicrosFieldOpt.isPresent());
+    HoodieSchema.Timestamp tsMicrosFieldSchema = (HoodieSchema.Timestamp) 
tsMicrosFieldOpt.get().schema();
+    assertEquals(HoodieSchemaType.TIMESTAMP, tsMicrosFieldSchema.getType());
+    assertEquals(TimePrecision.MICROS, tsMicrosFieldSchema.getPrecision());
+    assertTrue(tsMicrosFieldSchema.isUtcAdjusted());
+    if (tableVersion > 8 && !HoodieSparkUtils.isSpark3_3()) {
+      Option<HoodieSchemaField> localTsMillisFieldOpt = 
tableSchema.getField("local_ts_millis");
+      assertTrue(localTsMillisFieldOpt.isPresent());
+      HoodieSchema.Timestamp localTsMillisFieldSchema = 
(HoodieSchema.Timestamp) localTsMillisFieldOpt.get().schema();
+      assertEquals(HoodieSchemaType.TIMESTAMP, 
localTsMillisFieldSchema.getType());
+      assertEquals(TimePrecision.MILLIS, 
localTsMillisFieldSchema.getPrecision());
+      assertFalse(localTsMillisFieldSchema.isUtcAdjusted());
+
+      Option<HoodieSchemaField> localTsMicrosFieldOpt = 
tableSchema.getField("local_ts_micros");
+      assertTrue(localTsMicrosFieldOpt.isPresent());
+      HoodieSchema.Timestamp localTsMicrosFieldSchema = 
(HoodieSchema.Timestamp) localTsMicrosFieldOpt.get().schema();
+      assertEquals(HoodieSchemaType.TIMESTAMP, 
localTsMicrosFieldSchema.getType());
+      assertEquals(TimePrecision.MICROS, 
localTsMicrosFieldSchema.getPrecision());
+      assertFalse(localTsMicrosFieldSchema.isUtcAdjusted());
+    }
+    Option<HoodieSchemaField> eventDateFieldOpt = 
tableSchema.getField("event_date");
+    assertTrue(eventDateFieldOpt.isPresent());
+    assertEquals(HoodieSchemaType.DATE, 
eventDateFieldOpt.get().schema().getType());
+
+    if (tableVersion > 8) {
+      Option<HoodieSchemaField> decPlainLargeFieldOpt = 
tableSchema.getField("dec_plain_large");
+      assertTrue(decPlainLargeFieldOpt.isPresent());
+      HoodieSchema.Decimal decPlainLargeSchema = (HoodieSchema.Decimal) 
decPlainLargeFieldOpt.get().schema();
+      // decimal backed by bytes (are not fixed length byte arrays)
+      assertFalse(decPlainLargeSchema.isFixed());
+      assertEquals(HoodieSchemaType.DECIMAL, decPlainLargeSchema.getType());
+      assertEquals(20, decPlainLargeSchema.getPrecision());
+      assertEquals(10, decPlainLargeSchema.getScale());
+    }
+    Option<HoodieSchemaField> decFixedSmallOpt = 
tableSchema.getField("dec_fixed_small");
+    assertTrue(decFixedSmallOpt.isPresent());
+    HoodieSchema.Decimal decFixedSmallSchema = (HoodieSchema.Decimal) 
decFixedSmallOpt.get().schema();
+    assertTrue(decFixedSmallSchema.isFixed());
+    assertEquals(3, decFixedSmallSchema.getFixedSize());
+    assertEquals(HoodieSchemaType.DECIMAL, decFixedSmallSchema.getType());
+    assertEquals(5, decFixedSmallSchema.getPrecision());
+    assertEquals(2, decFixedSmallSchema.getScale());
+
+    Option<HoodieSchemaField> decFixedLargeOpt = 
tableSchema.getField("dec_fixed_large");
+    assertTrue(decFixedLargeOpt.isPresent());
+    HoodieSchema.Decimal decFixedLargeSchema = (HoodieSchema.Decimal) 
decFixedLargeOpt.get().schema();
+    assertTrue(decFixedLargeSchema.isFixed());
+    assertEquals(8, decFixedLargeSchema.getFixedSize());
+    assertEquals(HoodieSchemaType.DECIMAL, decFixedLargeSchema.getType());
+    assertEquals(18, decFixedLargeSchema.getPrecision());
+    assertEquals(9, decFixedLargeSchema.getScale());
+
+    sqlContext.clearCache();
+    Dataset<Row> df = sqlContext.read()
+        .options(hudiOpts)
+        .format("org.apache.hudi")
+        .load(tableBasePath);
+
+    long totalCount = df.count();
+    long expectedHalf = totalCount / 2;
+    long tolerance = totalCount / 20;
+    if (totalCount < 100) {
+      tolerance = totalCount / 4;
+    }
+
+    if (tableVersion > 8) {
+      assertHalfSplit(df, "ts_millis > timestamp('2020-01-01 00:00:00Z')", 
expectedHalf, tolerance, "ts_millis > threshold");
+      assertHalfSplit(df, "ts_millis < timestamp('2020-01-01 00:00:00Z')", 
expectedHalf, tolerance, "ts_millis < threshold");
+      assertBoundaryCounts(df, "ts_millis > timestamp('2020-01-01 
00:00:00.001Z')", "ts_millis <= timestamp('2020-01-01 00:00:00.001Z')", 
totalCount);
+      assertBoundaryCounts(df, "ts_millis < timestamp('2019-12-31 
23:59:59.999Z')", "ts_millis >= timestamp('2019-12-31 23:59:59.999Z')", 
totalCount);
+    }
+
+    assertHalfSplit(df, "ts_micros > timestamp('2020-06-01 12:00:00Z')", 
expectedHalf, tolerance, "ts_micros > threshold");
+    assertHalfSplit(df, "ts_micros < timestamp('2020-06-01 12:00:00Z')", 
expectedHalf, tolerance, "ts_micros < threshold");
+    assertBoundaryCounts(df, "ts_micros > timestamp('2020-06-01 
12:00:00.000001Z')", "ts_micros <= timestamp('2020-06-01 12:00:00.000001Z')", 
totalCount);
+    assertBoundaryCounts(df, "ts_micros < timestamp('2020-06-01 
11:59:59.999999Z')", "ts_micros >= timestamp('2020-06-01 11:59:59.999999Z')", 
totalCount);
+
+    if (tableVersion > 8 && !HoodieSparkUtils.isSpark3_3()) {
+      assertHalfSplit(df, "local_ts_millis > CAST('2015-05-20 12:34:56' AS 
TIMESTAMP_NTZ)", expectedHalf, tolerance, "local_ts_millis > threshold");
+      assertHalfSplit(df, "local_ts_millis < CAST('2015-05-20 12:34:56' AS 
TIMESTAMP_NTZ)", expectedHalf, tolerance, "local_ts_millis < threshold");
+      assertBoundaryCounts(df, "local_ts_millis > CAST('2015-05-20 
12:34:56.001' AS TIMESTAMP_NTZ)", "local_ts_millis <= CAST('2015-05-20 
12:34:56.001' AS TIMESTAMP_NTZ)", totalCount);
+      assertBoundaryCounts(df, "local_ts_millis < CAST('2015-05-20 
12:34:55.999' AS TIMESTAMP_NTZ)", "local_ts_millis >= CAST('2015-05-20 
12:34:55.999' AS TIMESTAMP_NTZ)", totalCount);
+
+      assertHalfSplit(df, "local_ts_micros > CAST('2017-07-07 07:07:07' AS 
TIMESTAMP_NTZ)", expectedHalf, tolerance, "local_ts_micros > threshold");
+      assertHalfSplit(df, "local_ts_micros < CAST('2017-07-07 07:07:07' AS 
TIMESTAMP_NTZ)", expectedHalf, tolerance, "local_ts_micros < threshold");
+      assertBoundaryCounts(df, "local_ts_micros > CAST('2017-07-07 
07:07:07.000001' AS TIMESTAMP_NTZ)", "local_ts_micros <= CAST('2017-07-07 
07:07:07.000001' AS TIMESTAMP_NTZ)", totalCount);
+      assertBoundaryCounts(df, "local_ts_micros < CAST('2017-07-07 
07:07:06.999999' AS TIMESTAMP_NTZ)", "local_ts_micros >= CAST('2017-07-07 
07:07:06.999999' AS TIMESTAMP_NTZ)", totalCount);
+
+    }
+
+    assertHalfSplit(df, "event_date > date('2000-01-01')", expectedHalf, 
tolerance, "event_date > threshold");
+    assertHalfSplit(df, "event_date < date('2000-01-01')", expectedHalf, 
tolerance, "event_date < threshold");
+    assertBoundaryCounts(df, "event_date > date('2000-01-02')", "event_date <= 
date('2000-01-02')", totalCount);
+    assertBoundaryCounts(df, "event_date < date('1999-12-31')", "event_date >= 
date('1999-12-31')", totalCount);
+
+    if (tableVersion > 8) {
+      assertHalfSplit(df, "dec_plain_large < 1234567890.0987654321", 
expectedHalf, tolerance, "dec_plain_large < threshold");
+      assertHalfSplit(df, "dec_plain_large > 1234567890.0987654321", 
expectedHalf, tolerance, "dec_plain_large > threshold");
+      assertBoundaryCounts(df, "dec_plain_large < 1234567890.0987654320", 
"dec_plain_large >= 1234567890.0987654320", totalCount);
+      assertBoundaryCounts(df, "dec_plain_large > 1234567890.0987654322", 
"dec_plain_large <= 1234567890.0987654322", totalCount);
+    }
+
+    assertHalfSplit(df, "dec_fixed_small < 543.21", expectedHalf, tolerance, 
"dec_fixed_small < threshold");
+    assertHalfSplit(df, "dec_fixed_small > 543.21", expectedHalf, tolerance, 
"dec_fixed_small > threshold");
+    assertBoundaryCounts(df, "dec_fixed_small < 543.20", "dec_fixed_small >= 
543.20", totalCount);
+    assertBoundaryCounts(df, "dec_fixed_small > 543.22", "dec_fixed_small <= 
543.22", totalCount);
+
+    assertHalfSplit(df, "dec_fixed_large < 987654321.123456789", expectedHalf, 
tolerance, "dec_fixed_large < threshold");
+    assertHalfSplit(df, "dec_fixed_large > 987654321.123456789", expectedHalf, 
tolerance, "dec_fixed_large > threshold");
+    assertBoundaryCounts(df, "dec_fixed_large < 987654321.123456788", 
"dec_fixed_large >= 987654321.123456788", totalCount);
+    assertBoundaryCounts(df, "dec_fixed_large > 987654321.123456790", 
"dec_fixed_large <= 987654321.123456790", totalCount);
+  }
+
+  private void assertHalfSplit(Dataset<Row> df, String filterExpr, long 
expectedHalf, long tolerance, String msg) {
+    long count = df.filter(filterExpr).count();
+    assertTrue(Math.abs(count - expectedHalf) <= tolerance, msg + " (got=" + 
count + ", expected=" + expectedHalf + ")");
+  }
+
+  private void assertBoundaryCounts(Dataset<Row> df, String exprZero, String 
exprTotal, long totalCount) {
+    assertEquals(0, df.filter(exprZero).count(), exprZero);
+    assertEquals(totalCount, df.filter(exprTotal).count(), exprTotal);
+  }
+
+  @ParameterizedTest
+  @CsvSource(value = {
+      // Repair succeeds when a per-field verdict is set, on the default 
(non-reconcile) write path...
+      "SIX,AVRO,CLUSTER,false,true", "EIGHT,AVRO,CLUSTER,false,true",
+      "CURRENT,AVRO,NONE,false,true", "CURRENT,AVRO,CLUSTER,false,true",
+      "CURRENT,SPARK,NONE,false,true", "CURRENT,SPARK,CLUSTER,false,true",
+      // ...and on the reconcile path (setNullForMissingColumns=true).
+      "SIX,AVRO,CLUSTER,true,true", "EIGHT,AVRO,CLUSTER,true,true", 
"CURRENT,AVRO,CLUSTER,true,true",
+      // Guard: with no verdict, the mislabeled timestamp/local-timestamp 
columns must be rejected on
+      // the first sync, on both the reconcile path and the default path.
+      "SIX,AVRO,CLUSTER,true,false", "SIX,AVRO,CLUSTER,false,false"})
+  void testCOWLogicalRepair(String tableVersion, String recordType, String 
operation,
+                            boolean setNullForMissingColumns,
+                            boolean setTimestampOverride) throws Exception {
+    TestMercifulJsonToRowConverterBase.timestampNTZCompatibility(() -> {
+      String dirName = "trips_logical_types_json_cow_write";
+      String dataPath = basePath + "/" + dirName;
+      java.nio.file.Path zipOutput = Paths.get(new URI(dataPath));
+      HoodieTestUtils.extractZipToDirectory("logical-repair/" + dirName + 
".zip", zipOutput, getClass());
+      String tableBasePath = zipOutput.toString();
+
+      TypedProperties properties = new TypedProperties();
+      String schemaPath = 
getClass().getClassLoader().getResource("logical-repair/schema.avsc").toURI().toString();
+      
properties.setProperty("hoodie.streamer.schemaprovider.source.schema.file", 
schemaPath);
+      
properties.setProperty("hoodie.streamer.schemaprovider.target.schema.file", 
schemaPath);
+      String inputDataPath = 
getClass().getClassLoader().getResource("logical-repair/cow_write_updates/2").toURI().toString();
+      properties.setProperty("hoodie.streamer.source.dfs.root", inputDataPath);
+
+      String mergerClass = getMergerClassForRecordType(recordType);
+      String tableVersionString = getTableVersionCode(tableVersion);
+
+      
properties.setProperty(HoodieWriteConfig.RECORD_MERGE_IMPL_CLASSES.key(), 
mergerClass);
+      properties.setProperty("hoodie.datasource.write.recordkey.field", 
"_row_key");
+      properties.setProperty("hoodie.datasource.write.precombine.field", 
"timestamp");
+      properties.setProperty("hoodie.datasource.write.partitionpath.field", 
"partition_path");
+      properties.setProperty("hoodie.datasource.write.keygenerator.class", 
"org.apache.hudi.keygen.SimpleKeyGenerator");
+      properties.setProperty("hoodie.cleaner.policy", "KEEP_LATEST_COMMITS");
+      properties.setProperty("hoodie.compact.inline", "false");
+      properties.setProperty("hoodie.metatata.enable", "true");
+      properties.setProperty("hoodie.parquet.small.file.limit", "-1");
+      properties.setProperty("hoodie.cleaner.commits.retained", "10");
+      properties.setProperty(HoodieWriteConfig.WRITE_TABLE_VERSION.key(), 
tableVersionString);
+      
properties.setProperty(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS.key(),
+          Boolean.toString(setNullForMissingColumns));
+      if (setTimestampOverride) {
+        // Per-field verdict authorizing the repair: relabel ts_millis to 
millis and attach the
+        // local-timestamp logical types 0.x dropped. ts_micros stays micros 
(no entry needed).
+        
properties.setProperty(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key(),
+            LOGICAL_REPAIR_TS_OVERRIDES);
+      }
+
+      Option<TypedProperties> propt = Option.of(properties);
+
+      if (!setTimestampOverride) {
+        // No per-field verdict. The mislabeled timestamp/local-timestamp 
columns must be rejected on
+        // the first sync rather than silently flipped, on both the reconcile 
and default write paths.
+        // syncOnce wraps the guard's SchemaCompatibilityException in a 
HoodieIngestionException, so
+        // walk the cause chain to assert on the underlying exception.
+        Throwable thrown = assertThrows(Exception.class,
+            () -> syncOnce(prepCfgForCowLogicalRepair(tableBasePath, "456"), 
propt));
+        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);
+        assertTrue(cause.getMessage().contains("column 'ts_millis'")
+                && cause.getMessage().contains("without an explicit"),
+            "Unexpected message: " + cause.getMessage());
+        return;
+      }
+
+      syncOnce(prepCfgForCowLogicalRepair(tableBasePath, "456"), propt);
+
+      inputDataPath = 
getClass().getClassLoader().getResource("logical-repair/cow_write_updates/3").toURI().toString();
+      propt.get().setProperty("hoodie.streamer.source.dfs.root", 
inputDataPath);
+      if ("CLUSTER".equals(operation)) {
+        propt.get().setProperty("hoodie.clustering.inline", "true");
+        propt.get().setProperty("hoodie.clustering.inline.max.commits", "1");
+        
propt.get().setProperty("hoodie.clustering.plan.strategy.single.group.clustering.enabled",
 "true");
+        
propt.get().setProperty("hoodie.clustering.plan.strategy.sort.columns", 
"ts_millis,_row_key");
+      }
+      syncOnce(prepCfgForCowLogicalRepair(tableBasePath, "789"), propt);
+
+      String prevTimezone = 
sparkSession.conf().get("spark.sql.session.timeZone");
+      try {
+        sparkSession.conf().set("spark.sql.session.timeZone", "UTC");
+        Dataset<Row> df = 
sparkSession.read().format("hudi").load(tableBasePath);
+
+        assertDataframe(df, 15, 15);
+
+        if ("CLUSTER".equals(operation)) {
+          // after we cluster, the raw parquet should be correct
+
+          // Validate raw parquet files
+          HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder()
+              .setConf(storage.getConf())
+              .setBasePath(tableBasePath)
+              .build();
+
+          HoodieTimeline completedCommitsTimeline = 
metaClient.getCommitsTimeline().filterCompletedInstants();
+          Option<HoodieInstant> latestInstant = 
completedCommitsTimeline.lastInstant();
+          assertTrue(latestInstant.isPresent(), "No completed commits found");
+
+          List<String> baseFilePaths = collectLatestBaseFilePaths(metaClient);
+
+          assertEquals(4, baseFilePaths.size());
+
+          // Read raw parquet files
+          Dataset<Row> rawParquetDf = 
sparkSession.read().parquet(baseFilePaths.toArray(new String[0]));
+          assertDataframe(rawParquetDf, 15, 15);
+        }
+      } finally {
+        sparkSession.conf().set("spark.sql.session.timeZone", prevTimezone);
+      }
+    });
+  }
+
+  @ParameterizedTest
+  @CsvSource(value = {"SIX,AVRO,CLUSTER,AVRO,false,true", 
"EIGHT,AVRO,CLUSTER,AVRO,false,true",
+      "CURRENT,AVRO,NONE,AVRO,false,true", 
"CURRENT,AVRO,CLUSTER,AVRO,false,true", "CURRENT,AVRO,COMPACT,AVRO,false,true",
+      "CURRENT,AVRO,NONE,PARQUET,false,true", 
"CURRENT,AVRO,CLUSTER,PARQUET,false,true", 
"CURRENT,AVRO,COMPACT,PARQUET,false,true",
+      "CURRENT,SPARK,NONE,PARQUET,false,true", 
"CURRENT,SPARK,CLUSTER,PARQUET,false,true", 
"CURRENT,SPARK,COMPACT,PARQUET,false,true",
+      // Variants that exercise the schema-reconcile path 
(setNullForMissingColumns=true) with a verdict.
+      "SIX,AVRO,CLUSTER,AVRO,true,true", "EIGHT,AVRO,CLUSTER,AVRO,true,true", 
"CURRENT,AVRO,CLUSTER,AVRO,true,true",
+      // Guard: with no verdict, the first sync must throw, on both the 
reconcile and default paths.
+      "SIX,AVRO,CLUSTER,AVRO,true,false", "SIX,AVRO,CLUSTER,AVRO,false,false"})
+  void testMORLogicalRepair(String tableVersion, String recordType, String 
operation, String logBlockType,
+                            boolean setNullForMissingColumns,
+                            boolean setTimestampOverride) throws Exception {
+    TestMercifulJsonToRowConverterBase.timestampNTZCompatibility(() -> {
+      String tableSuffix;
+      String logFormatValue;
+      if ("AVRO".equals(logBlockType)) {
+        logFormatValue = "avro";
+        tableSuffix = "avro_log";
+      } else {
+        logFormatValue = "parquet";
+        tableSuffix = "parquet_log";
+      }
+
+      String dirName = "trips_logical_types_json_mor_write_" + tableSuffix;
+      String dataPath = basePath + "/" + dirName;
+      java.nio.file.Path zipOutput = Paths.get(new URI(dataPath));
+      HoodieTestUtils.extractZipToDirectory("logical-repair/" + dirName + 
".zip", zipOutput, getClass());
+      String tableBasePath = zipOutput.toString();
+
+      HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder()
+          .setConf(storage.getConf())
+          .setBasePath(tableBasePath)
+          .build();
+
+      // validate no compaction and clustering instants present in the timeline
+      HoodieTimeline completedTimeline = 
metaClient.getActiveTimeline().filterCompletedInstants();
+      assertFalse(completedTimeline.getInstants().stream().anyMatch(i -> 
i.getAction().equals(HoodieTimeline.COMPACTION_ACTION)));
+      assertFalse(completedTimeline.getInstants().stream().anyMatch(i -> 
i.getAction().equals(HoodieTimeline.CLUSTERING_ACTION)));
+
+      TypedProperties properties = new TypedProperties();
+      String schemaPath = 
getClass().getClassLoader().getResource("logical-repair/schema.avsc").toURI().toString();
+      
properties.setProperty("hoodie.streamer.schemaprovider.source.schema.file", 
schemaPath);
+      
properties.setProperty("hoodie.streamer.schemaprovider.target.schema.file", 
schemaPath);
+      String inputDataPath = 
getClass().getClassLoader().getResource("logical-repair/mor_write_updates/5").toURI().toString();
+      properties.setProperty("hoodie.streamer.source.dfs.root", inputDataPath);
+      String mergerClass = getMergerClassForRecordType(recordType);
+      String tableVersionString = getTableVersionCode(tableVersion);
+
+      
properties.setProperty(HoodieWriteConfig.RECORD_MERGE_IMPL_CLASSES.key(), 
mergerClass);
+      properties.setProperty("hoodie.datasource.write.recordkey.field", 
"_row_key");
+      properties.setProperty("hoodie.datasource.write.precombine.field", 
"timestamp");
+      properties.setProperty("hoodie.datasource.write.partitionpath.field", 
"partition_path");
+      properties.setProperty("hoodie.datasource.write.keygenerator.class", 
"org.apache.hudi.keygen.SimpleKeyGenerator");
+      properties.setProperty("hoodie.cleaner.policy", "KEEP_LATEST_COMMITS");
+      properties.setProperty("hoodie.metatata.enable", "true");
+      properties.setProperty("hoodie.parquet.small.file.limit", "-1");
+      properties.setProperty("hoodie.cleaner.commits.retained", "10");
+      properties.setProperty(HoodieWriteConfig.WRITE_TABLE_VERSION.key(), 
tableVersionString);
+      
properties.setProperty(HoodieStorageConfig.LOGFILE_DATA_BLOCK_FORMAT.key(), 
logFormatValue);
+      
properties.setProperty(HoodieCommonConfig.SET_NULL_FOR_MISSING_COLUMNS.key(),
+          Boolean.toString(setNullForMissingColumns));
+      if (setTimestampOverride) {
+        
properties.setProperty(HoodieCommonConfig.TIMESTAMP_LOGICAL_TYPE_OVERRIDES.key(),
+            LOGICAL_REPAIR_TS_OVERRIDES);
+      }
+
+      boolean disableCompaction;
+      if ("COMPACT".equals(operation)) {
+        properties.setProperty("hoodie.compact.inline", "true");
+        properties.setProperty("hoodie.compact.inline.max.delta.commits", "1");
+        disableCompaction = false;
+        // validate that there are no completed compaction (commit) instants 
in timeline.
+      } else {
+        properties.setProperty("hoodie.compact.inline", "false");
+        disableCompaction = true;
+      }
+
+      if ("CLUSTER".equals(operation)) {
+        properties.setProperty("hoodie.clustering.inline", "true");
+        properties.setProperty("hoodie.clustering.inline.max.commits", "1");
+        
properties.setProperty("hoodie.clustering.plan.strategy.single.group.clustering.enabled",
 "true");
+        properties.setProperty("hoodie.clustering.plan.strategy.sort.columns", 
"ts_millis,_row_key");
+      }
+
+      Option<TypedProperties> propt = Option.of(properties);
+
+      if (!setTimestampOverride) {
+        // No per-field verdict. The mislabeled timestamp/local-timestamp 
columns must be rejected on
+        // the first sync rather than silently flipped, on both the reconcile 
and default write paths.
+        // syncOnce wraps the guard's SchemaCompatibilityException in a 
HoodieIngestionException, so
+        // walk the cause chain to assert on the underlying exception.
+        Throwable thrown = assertThrows(Exception.class,
+            () -> syncOnce(prepCfgForMorLogicalRepair(tableBasePath, dirName, 
"123", disableCompaction), propt));
+        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);
+        assertTrue(cause.getMessage().contains("column 'ts_millis'")
+                && cause.getMessage().contains("without an explicit"),
+            "Unexpected message: " + cause.getMessage());
+        return;
+      }
+
+      syncOnce(prepCfgForMorLogicalRepair(tableBasePath, dirName, "123", 
disableCompaction), propt);
+
+      String prevTimezone = 
sparkSession.conf().get("spark.sql.session.timeZone");
+      try {
+        if (!HoodieSparkUtils.gteqSpark3_5()) {
+          sparkSession.conf().set("spark.sql.parquet.enableVectorizedReader", 
"false");
+        }
+        sparkSession.conf().set("spark.sql.session.timeZone", "UTC");
+        Dataset<Row> df = 
sparkSession.read().format("hudi").load(tableBasePath);
+
+        assertDataframe(df, 12, 14);
+
+        metaClient = HoodieTableMetaClient.builder()
+            .setConf(storage.getConf())
+            .setBasePath(tableBasePath)
+            .build();
+
+        if ("CLUSTER".equals(operation)) {
+          // after we cluster, the raw parquet should be correct
+
+          // Validate raw parquet files
+          HoodieTimeline completedCommitsTimeline = 
metaClient.getCommitsTimeline().filterCompletedInstants();
+          Option<HoodieInstant> latestInstant = 
completedCommitsTimeline.lastInstant();
+          assertTrue(latestInstant.isPresent(), "No completed commits found");
+
+          List<String> baseFilePaths = collectLatestBaseFilePaths(metaClient);
+
+          assertEquals(3, baseFilePaths.size());
+
+          // Read raw parquet files
+          Dataset<Row> rawParquetDf = 
sparkSession.read().parquet(baseFilePaths.toArray(new String[0]));
+          assertDataframe(rawParquetDf, 12, 14);
+        } else if ("COMPACT".equals(operation)) {
+          // after compaction some files should be ok
+
+          // Validate raw parquet files
+          HoodieTimeline completedCommitsTimeline = 
metaClient.getCommitsTimeline().filterCompletedInstants();
+          Option<HoodieInstant> latestInstant = 
completedCommitsTimeline.lastInstant();
+          assertTrue(latestInstant.isPresent(), "No completed commits found");
+
+          List<String> baseFilePaths = collectLatestBaseFilePaths(metaClient);
+
+          assertEquals(7, baseFilePaths.size());
+
+          // Read raw parquet files
+          Dataset<Row> rawParquetDf = 
sparkSession.read().parquet(baseFilePaths.stream()
+              // only read the compacted ones, the others are still incorrect
+              .filter(path -> 
path.contains(latestInstant.get().requestedTime()))
+              .toArray(String[]::new));
+          assertDataframe(rawParquetDf, 2, 3);
+        }
+      } finally {
+        sparkSession.conf().set("spark.sql.session.timeZone", prevTimezone);
+        if (!HoodieSparkUtils.gteqSpark3_5()) {
+          sparkSession.conf().set("spark.sql.parquet.enableVectorizedReader", 
"true");
+        }
+      }
+    });
+  }
+
+  public static void assertDataframe(Dataset<Row> df, int above, int below) {
+    List<Row> rows = df.collectAsList();
+    assertEquals(above + below, rows.size());
+
+    for (Row row : rows) {
+      String val = row.getString(6);
+      int hash = val.hashCode();
+
+      if ((hash & 1) == 0) {
+        assertEquals("2020-01-01T00:00:00.001Z", 
row.getTimestamp(15).toInstant().toString());
+        assertEquals("2020-06-01T12:00:00.000001Z", 
row.getTimestamp(16).toInstant().toString());
+        assertEquals("2015-05-20T12:34:56.001", row.get(17).toString());
+        assertEquals("2017-07-07T07:07:07.000001", row.get(18).toString());
+      } else {
+        assertEquals("2019-12-31T23:59:59.999Z", 
row.getTimestamp(15).toInstant().toString());
+        assertEquals("2020-06-01T11:59:59.999999Z", 
row.getTimestamp(16).toInstant().toString());
+        assertEquals("2015-05-20T12:34:55.999", row.get(17).toString());
+        assertEquals("2017-07-07T07:07:06.999999", row.get(18).toString());
+      }
+    }
+
+    assertEquals(above, df.filter("ts_millis > timestamp('2020-01-01 
00:00:00Z')").count());

Review Comment:
   🤖 nit: `getMergerClassForRecordType`/`getTableVersionCode` take String args 
("AVRO"/"SPARK", "SIX"/"EIGHT"/"CURRENT") duplicating existing enums 
(`HoodieRecordType`, `HoodieTableVersion`) instead of using `@CsvSource` with 
the enum types directly — this loses compile-time safety and needs a 
switch+default throw to catch typos.
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/ExclusiveSuite.scala:
##########
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.hudi.common
+
+import org.scalatest.{Args, Status}
+
+/**
+ * Marks a suite that must not run at the same time as any other suite in the 
JVM because it
+ * mutates JVM-wide state (the shared Hadoop conf, persisted RDDs of the 
shared context, a
+ * static metrics registry). Takes the write side of 
[[HoodieSparkSqlTestBase.suiteLock]] for
+ * its whole run; every other suite holds the read side for its whole run. An 
exclusive suite
+ * therefore waits for the suites already running to finish, not just for 
their current test. No
+ * effect until suites run concurrently.
+ */
+trait ExclusiveSuite extends HoodieSparkSqlTestBase {

Review Comment:
   🤖 A few more suites seem to need this. 
`HoodieBackedTableMetadataIndexLookup*` unpersists every persisted RDD on the 
(shared) SparkContext and asserts `getPersistentRDDs.isEmpty`. 
TestHiveSyncProcedure (other-1, a shared-session shard) sets and unsets 
`hive.metastore.uris` on the shared Hadoop conf. Should they be exclusive too?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/testutils/HiveTestService.java:
##########
@@ -63,7 +63,9 @@
 public class HiveTestService {
   private static final int CONNECTION_TIMEOUT_MS = 30000;
   private static final String BIND_HOST = "127.0.0.1";
-  private static final int HS2_THRIFT_PORT = 9999;
+  // Chosen once per JVM rather than fixed at 9999 so that surefire forks 
running this module concurrently
+  // each get their own HiveServer2 port; within one JVM every HiveTestService 
instance still shares the port, as before.
+  private static final int HS2_THRIFT_PORT = NetworkTestUtils.nextFreePort();

Review Comment:
   🤖 This port is chosen once at class load but only bound while the service is 
running. Between per-class stop/start cycles it's a free ephemeral port, so a 
sibling fork's `nextFreePort()`, a Spark bind or a localhost client socket can 
take it, and the next start hits a BindException. The same applies to 
`ZookeeperTestService.CLIENT_PORT`. Have you considered deriving it from 
`${surefire.forkNumber}` outside the ephemeral range (e.g. 9999 + forkNumber)?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



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