This is an automated email from the ASF dual-hosted git repository.

voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 842e84e4d4fe test(variant): pin Spark 3.x schema-on-read rejection of 
variant tables (#19691)
842e84e4d4fe is described below

commit 842e84e4d4fe2fe858408142adc914d681fcb274
Author: voonhous <[email protected]>
AuthorDate: Mon Aug 24 20:15:23 2026 +0800

    test(variant): pin Spark 3.x schema-on-read rejection of variant tables 
(#19691)
    
    * test(variant): pin Spark 3.x schema-on-read rejection of variant tables
    
    Verifies #18021: with hoodie.schema.on.read.enable, Spark 3.x resolves
    the schema through the InternalSchema round trip, whose sentinel
    detection restores the VARIANT logical type - the input
    HoodieSparkSchemaConverters rejects on Spark 3.x. Every leg fails
    loudly with the same actionable error as plain auto-resolution
    (HoodieSchemaException caused by 'VARIANT type is only supported in
    Spark 4.0+'); no silent wrong data, no obscure secondary failure.
    
    The notable interaction: the documented struct-DDL compat mode works
    without schema-on-read but breaks once the table carries an internal
    schema and the conf is on, because internal-schema resolution overrides
    the user's DDL. Real support is #18285; until then Spark 3.x
    compat-mode readers must keep the conf off. Fixture: a Spark
    4.1-written COW variant table with a committed internal schema.
    
    * review(variant): CI-enforce the pin, prove both sides of the compat 
interaction
    
    - Add org.apache.spark.sql.hudi.dml.schema to the Azure DDL & Others
      wildcard set so the Spark 3-gated pins actually run on the spark3.5
      lane (the package was in no Azure set, and the GHA dml lane is
      spark4.2-only).
    - Assert the fixture carries a loadable internal schema so the
      schema-on-read legs cannot pass via the commit-metadata fallback.
    - Add a conf-off struct-DDL leg on the same fixture, proving the conf,
      not the committed internal schema itself, is what breaks compat mode.
    - Record the exact seeding statements in the fixture README so the zip
      is regenerable.
    - Drop the answered #18021 TODO in HoodieSparkSchemaConverters.
    
    * review(variant): narrow the pin's claims and guard the Azure leaf split
    
    - README: the rejection test reads `latest_schema` out of the newest
      schema-capable commit's extra metadata, not `.hoodie/.schema/`, so point
      the regeneration check there (the first insert in this fixture has the
      schemacommit files and no `latest_schema`).
    - Scope the "keep hoodie.schema.on.read.enable off" advice to the non-Hive
      catalog: DefaultSource discards the user schema when isUsingHiveCatalog,
      so under HMS the struct DDL read throws with the conf off too.
    - Narrow the fixture assert's claim: HoodieBaseHadoopFsRelationFactory
      (reached via HoodieCopyOnWriteSnapshotHadoopFsRelationFactory, not
      HoodieBaseRelation) swallows an internal-schema load failure into None
      and falls back to getTableSchema, so the assert pins the fixture, not the
      path; the conf-off/conf-on catalog-DDL pair is what discriminates.
    - Pin the exact variant value/metadata bytes on the conf-off leg instead of
      just isNullAt, so a swapped or malformed pair cannot pass.
    - Guard the Azure leaf split in TestSparkSqlHudiPackageStructure: assert
      every Scala test package under org.apache.spark.sql.hudi is named by some
      wildcardSuites entry, so the next dml.* package cannot start out dark.
    - Rename the assertVariantRejected parameter leg -> label.
    
    * review(ci): assert an Azure step actually runs each Scala test package
    
    The previous guard only proved a package was named by some wildcardSuites
    entry. It did not tie that entry back to a step that expands it, nor check
    the step's -pl builds the module holding the class - so a parameter list no
    step referenced, or a step that stopped building hudi-spark, still read as
    covered.
    
    Resolve the pipeline properly instead: parse the parameters: lists, map the
    variables: entries that join them, then for each step carrying both
    -DwildcardSuites and -pl expand the $(VAR) tokens and record the pair. A
    class counts as run only when some step both builds its module and names its
    package. Renamed to testScalaTestPackagesAreRunByAnAzureStep to match.
    
    Verified it still passes on the tree as-is and fails on all three of: a new
    dml.darkpkg package, deleting the step that expands
    JOB6_SPARK_DDL_OTHERS_WILDCARD_SUITES while keeping the list, and pointing
    that step's -pl at a module other than hudi-spark.
---
 azure-pipelines-20230430.yml                       |   1 +
 .../sql/avro/HoodieSparkSchemaConverters.scala     |   1 -
 .../resources/variant_backward_compat/README.md    |  39 ++++
 .../variant_schema_on_read_cow.zip                 | Bin 0 -> 105946 bytes
 .../hudi/TestSparkSqlHudiPackageStructure.java     | 221 ++++++++++++++++++++-
 .../sql/hudi/dml/schema/TestVariantDataType.scala  | 102 ++++++++++
 6 files changed, 356 insertions(+), 8 deletions(-)

diff --git a/azure-pipelines-20230430.yml b/azure-pipelines-20230430.yml
index a8bd40223295..617bc4d9e418 100644
--- a/azure-pipelines-20230430.yml
+++ b/azure-pipelines-20230430.yml
@@ -80,6 +80,7 @@ parameters:
       - 'org.apache.spark.sql.hudi.command'
       - 'org.apache.spark.sql.hudi.common'
       - 'org.apache.spark.sql.hudi.ddl'
+      - 'org.apache.spark.sql.hudi.dml.schema'
       - 'org.apache.spark.sql.hudi.procedure'
   - name: jacocoModules
     type: object
diff --git 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/avro/HoodieSparkSchemaConverters.scala
 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/avro/HoodieSparkSchemaConverters.scala
index 2ba3fcfc158b..dc925e60a624 100644
--- 
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/avro/HoodieSparkSchemaConverters.scala
+++ 
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/avro/HoodieSparkSchemaConverters.scala
@@ -395,7 +395,6 @@ object HoodieSparkSchemaConverters extends 
SparkAdapterSupport {
         }
 
       // VARIANT type (Spark >4.x only), which will be handled via SparkAdapter
-      // TODO: Check if internalSchema will throw any errors here: #18021
       case HoodieSchemaType.VARIANT =>
         sparkAdapter.getVariantDataType match {
           case Some(variantType) => SchemaType(variantType, nullable = false)
diff --git a/hudi-common/src/test/resources/variant_backward_compat/README.md 
b/hudi-common/src/test/resources/variant_backward_compat/README.md
index 94d45541b0bb..d297feeb9393 100644
--- a/hudi-common/src/test/resources/variant_backward_compat/README.md
+++ b/hudi-common/src/test/resources/variant_backward_compat/README.md
@@ -39,3 +39,42 @@ The test runs on these four arguments:
 COW tables generated are the same for both AVRO/SPARK. But for MOR, the log 
files metadata are 
 different. Hence, we only need to generate test files for either 1/2, 3 and 4, 
hence, 3 test 
 resource files.
+
+# variant_schema_on_read_cow.zip
+
+A Spark 4.1-written COW variant table carrying a COMMITTED INTERNAL SCHEMA: 
one insert, then a
+schema-on-read DDL, then a second insert. Used by the Spark 3.x schema-on-read 
rejection test
+(#18021). Generated by running these exact statements in a Spark 4.1 SQL 
session:
+
+```sql
+create table variant_schema_on_read_cow (
+  id int,
+  v variant,
+  ts long
+) using hudi
+location '/path/to/table/parent/variant_schema_on_read_cow'
+tblproperties (
+  primaryKey = 'id',
+  preCombineField = 'ts'
+);
+
+insert into variant_schema_on_read_cow select 1, parse_json('{"key": "v1"}'), 
1000;
+
+set hoodie.schema.on.read.enable=true;
+alter table variant_schema_on_read_cow add columns (note string);
+insert into variant_schema_on_read_cow select 2, parse_json('{"key": "v2"}'), 
1000, 'n2';
+```
+
+and zipping the table directory:
+
+```shell
+cd /path/to/table/parent/
+zip -r variant_schema_on_read_cow.zip variant_schema_on_read_cow
+```
+
+The rejection test asserts 
`TableSchemaResolver.getTableInternalSchemaFromCommitMetadata`, which
+reads the `latest_schema` key out of the newest schema-capable commit's extra 
metadata - NOT out of
+`.hoodie/.schema/`, which is a separate store. So after regenerating, confirm 
the last commit in
+`.hoodie/timeline/` carries `latest_schema`; the presence of 
`.hoodie/.schema/` schemacommit files
+does not imply it (the first insert here has those and no `latest_schema`). 
The commit is Avro, so
+`strings <instant>_<completion>.commit | grep latest_schema` is enough to 
check.
diff --git 
a/hudi-common/src/test/resources/variant_backward_compat/variant_schema_on_read_cow.zip
 
b/hudi-common/src/test/resources/variant_backward_compat/variant_schema_on_read_cow.zip
new file mode 100644
index 000000000000..448ba2c7f758
Binary files /dev/null and 
b/hudi-common/src/test/resources/variant_backward_compat/variant_schema_on_read_cow.zip
 differ
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/TestSparkSqlHudiPackageStructure.java
 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/TestSparkSqlHudiPackageStructure.java
index ddce176eb738..c37525c8e356 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/TestSparkSqlHudiPackageStructure.java
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/TestSparkSqlHudiPackageStructure.java
@@ -21,13 +21,20 @@ package org.apache.hudi;
 import org.junit.jupiter.api.Test;
 
 import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import java.util.TreeMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -42,6 +49,18 @@ public class TestSparkSqlHudiPackageStructure {
 
   private static final String BASE_PACKAGE = "org.apache.spark.sql.hudi";
   private static final String PACKAGE_PATH = "org/apache/spark/sql/hudi";
+  private static final String AZURE_PIPELINE_FILE = 
"azure-pipelines-20230430.yml";
+  private static final String SPARK_DATASOURCE_DIR = "hudi-spark-datasource";
+
+  private static final Pattern PARAM_NAME = Pattern.compile("^\\s*- 
name:\\s*(\\S+)\\s*$");
+  private static final Pattern LIST_ITEM = Pattern.compile("^\\s*- 
'([^']*)'\\s*$");
+  /** A {@code variables:} entry that is just a join over a parameter list, 
e.g. {@code
+   * JOB3456_MODULES: ${{ join(',',parameters.job3456UTModules) }}}. */
+  private static final Pattern VARIABLE_JOIN =
+      
Pattern.compile("^\\s*([A-Za-z0-9_]+):\\s*\\$\\{\\{\\s*join\\('[^']*',\\s*parameters\\.([A-Za-z0-9_]+)\\)\\s*\\}\\}\\s*$");
+  private static final Pattern WILDCARD_CLI_ARG = 
Pattern.compile("-DwildcardSuites=\"?([^\" ]+)");
+  private static final Pattern PL_CLI_ARG = Pattern.compile("-pl\\s+\"?([^\" 
]+)");
+  private static final Pattern VAR_REF = 
Pattern.compile("\\$\\(([A-Za-z0-9_]+)\\)");
 
   /**
    * Allowed sub-packages under org.apache.spark.sql.hudi for Scala test 
classes.
@@ -89,6 +108,180 @@ public class TestSparkSqlHudiPackageStructure {
         "Expected to find at least one Scala test class in " + BASE_PACKAGE);
   }
 
+  /**
+   * Every Scala test class under {@link #BASE_PACKAGE} must be run by at 
least one Azure step:
+   * some step whose {@code -pl} builds the module holding the class must also 
pass a
+   * {@code -DwildcardSuites} prefix matching it. Otherwise the class silently 
never runs on Azure.
+   *
+   * <p>The Azure jobs deliberately name leaf packages ({@code dml.others}, 
{@code dml.insert},
+   * {@code dml.schema}) rather than the recursive {@code dml} parent, because 
ScalaTest's
+   * {@code -w} is a plain prefix match with no exclusion primitive: pointing 
one job at
+   * {@code ...hudi.dml} would re-run the whole {@code dml.insert} set that 
already has its own
+   * job. That split is what makes a newly added {@code dml.*} package start 
out dark, so this
+   * test is the guard for it - the other, non-recursive {@code 
testSparkSqlHudi...} check above
+   * lets {@code dml.*} through because it treats {@code dml} as one allowed 
package.
+   */
+  @Test
+  public void testScalaTestPackagesAreRunByAnAzureStep() {
+    List<AzureScalaTestStep> steps = readAzureScalaTestSteps();
+    assertFalse(steps.isEmpty(),
+        "Expected to parse at least one -DwildcardSuites step from " + 
AZURE_PIPELINE_FILE
+            + "; the parsing below has probably drifted from the pipeline's 
shape");
+
+    Map<String, List<String>> classesByModule = findScalaTestClassesByModule();
+    List<String> uncovered = new ArrayList<>();
+    for (Map.Entry<String, List<String>> entry : classesByModule.entrySet()) {
+      String module = entry.getKey();
+      for (String className : entry.getValue()) {
+        boolean run = steps.stream().anyMatch(step -> step.builds(module) && 
step.runs(className));
+        if (!run) {
+          uncovered.add(className + "  (module " + module + ")");
+        }
+      }
+    }
+
+    if (!uncovered.isEmpty()) {
+      StringBuilder message = new StringBuilder();
+      message.append("Found Scala test classes that no Azure step runs - no 
step both builds ")
+          .append("their module (-pl) and names their package 
(-DwildcardSuites), so they never ")
+          .append("run on Azure CI.\n\nUncovered classes:\n");
+      uncovered.forEach(cls -> message.append("  - 
").append(cls).append("\n"));
+      message.append("\nAdd their package to one of the wildcardSuites sets in 
")
+          .append(AZURE_PIPELINE_FILE)
+          .append(" (e.g. 'job6HudiSparkDdlOthersWildcardSuites'), balancing 
against job runtime.");
+      fail(message.toString());
+    }
+  }
+
+  /**
+   * One Azure step that runs ScalaTest: the suite prefixes it passes to 
{@code -DwildcardSuites}
+   * and the module list it passes to {@code -pl}, both with {@code $(VAR)} 
references resolved
+   * back to the parameter list the {@code variables:} block joins them from.
+   */
+  private static final class AzureScalaTestStep {
+    private final List<String> suitePrefixes;
+    private final List<String> modules;
+
+    private AzureScalaTestStep(List<String> suitePrefixes, List<String> 
modules) {
+      this.suitePrefixes = suitePrefixes;
+      this.modules = modules;
+    }
+
+    /** Whether this step's {@code -pl} selects the given module. */
+    private boolean builds(String module) {
+      if (modules.isEmpty()) {
+        // no -pl at all means the whole reactor
+        return true;
+      }
+      if (modules.contains("!" + module)) {
+        return false;
+      }
+      // an all-exclusion list selects everything it does not name; otherwise 
it is an include list
+      boolean allExclusions = modules.stream().allMatch(m -> 
m.startsWith("!"));
+      return allExclusions || modules.contains(module);
+    }
+
+    /** Whether this step's {@code -DwildcardSuites} names the given class. 
ScalaTest's -w is a
+     * prefix match; the dot boundary keeps this check strictly narrower than 
what -w accepts. */
+    private boolean runs(String className) {
+      return suitePrefixes.stream()
+          .anyMatch(prefix -> className.equals(prefix) || 
className.startsWith(prefix + "."));
+    }
+  }
+
+  /**
+   * Parses the pipeline into the ScalaTest steps it defines. Resolves {@code 
$(VAR)} tokens
+   * through the {@code variables:} block back to the {@code parameters:} list 
they join, so a
+   * parameter list that no step actually expands contributes nothing.
+   */
+  private List<AzureScalaTestStep> readAzureScalaTestSteps() {
+    List<String> lines = readAzurePipelineLines();
+
+    // parameters: <name> -> its list of values
+    Map<String, List<String>> parameterLists = new HashMap<>();
+    String currentParam = null;
+    for (String line : lines) {
+      Matcher paramName = PARAM_NAME.matcher(line);
+      if (paramName.matches()) {
+        currentParam = paramName.group(1);
+        parameterLists.put(currentParam, new ArrayList<>());
+        continue;
+      }
+      Matcher listItem = LIST_ITEM.matcher(line);
+      if (currentParam != null && listItem.matches()) {
+        parameterLists.get(currentParam).add(listItem.group(1).trim());
+      }
+    }
+
+    // variables: <VAR> -> the parameter list it joins
+    Map<String, List<String>> variableValues = new HashMap<>();
+    for (String line : lines) {
+      Matcher variable = VARIABLE_JOIN.matcher(line);
+      if (variable.matches()) {
+        List<String> values = parameterLists.get(variable.group(2));
+        if (values != null) {
+          variableValues.put(variable.group(1), values);
+        }
+      }
+    }
+
+    // steps: every line carrying both a wildcardSuites filter and a -pl 
module list
+    List<AzureScalaTestStep> steps = new ArrayList<>();
+    for (String line : lines) {
+      Matcher suites = WILDCARD_CLI_ARG.matcher(line);
+      Matcher modules = PL_CLI_ARG.matcher(line);
+      if (!suites.find() || !modules.find()) {
+        continue;
+      }
+      List<String> suitePrefixes = expand(suites.group(1), 
variableValues).stream()
+          .filter(suite -> suite.startsWith("org."))
+          .collect(Collectors.toList());
+      if (suitePrefixes.isEmpty()) {
+        // e.g. the java-only steps, which pass -DwildcardSuites=skipScalaTests
+        continue;
+      }
+      steps.add(new AzureScalaTestStep(suitePrefixes, expand(modules.group(1), 
variableValues)));
+    }
+    return steps;
+  }
+
+  /**
+   * Splits a comma-separated Azure argument into its entries, replacing any 
{@code $(VAR)} token
+   * with the entries of the parameter list that variable joins.
+   */
+  private List<String> expand(String argument, Map<String, List<String>> 
variableValues) {
+    List<String> expanded = new ArrayList<>();
+    for (String token : argument.split(",")) {
+      String trimmed = token.trim();
+      Matcher varRef = VAR_REF.matcher(trimmed);
+      if (varRef.matches()) {
+        expanded.addAll(variableValues.getOrDefault(varRef.group(1), new 
ArrayList<>()));
+      } else if (!trimmed.isEmpty()) {
+        expanded.add(trimmed);
+      }
+    }
+    return expanded;
+  }
+
+  private List<String> readAzurePipelineLines() {
+    File projectRoot = findProjectRoot();
+    if (projectRoot == null) {
+      fail("Could not locate project root directory");
+      return new ArrayList<>();
+    }
+    File pipeline = new File(projectRoot, AZURE_PIPELINE_FILE);
+    if (!pipeline.exists()) {
+      fail("Could not locate " + AZURE_PIPELINE_FILE + " at " + 
pipeline.getAbsolutePath());
+      return new ArrayList<>();
+    }
+    try {
+      return Files.readAllLines(pipeline.toPath(), StandardCharsets.UTF_8);
+    } catch (Exception e) {
+      fail("Could not read " + AZURE_PIPELINE_FILE + ": " + e.getMessage());
+      return new ArrayList<>();
+    }
+  }
+
   /**
    * Checks if a class is in one of the allowed packages (including 
sub-packages).
    */
@@ -105,20 +298,31 @@ public class TestSparkSqlHudiPackageStructure {
    * Finds all Scala test classes under org.apache.spark.sql.hudi by scanning 
source directories.
    */
   private List<String> findScalaTestClasses() {
-    List<String> classes = new ArrayList<>();
+    return findScalaTestClassesByModule().values().stream()
+        .flatMap(List::stream)
+        .collect(Collectors.toList());
+  }
+
+  /**
+   * Finds all Scala test classes under org.apache.spark.sql.hudi, keyed by 
the Maven module path
+   * that holds them (as Azure would name it in {@code -pl}), e.g.
+   * {@code hudi-spark-datasource/hudi-spark}.
+   */
+  private Map<String, List<String>> findScalaTestClassesByModule() {
+    Map<String, List<String>> classesByModule = new TreeMap<>();
 
     // Find project root by traversing up from current class location
     File projectRoot = findProjectRoot();
     if (projectRoot == null) {
       fail("Could not locate project root directory");
-      return classes;
+      return classesByModule;
     }
 
     // Scan all hudi-spark-datasource modules for Scala test sources
-    File sparkDatasourceDir = new File(projectRoot, "hudi-spark-datasource");
+    File sparkDatasourceDir = new File(projectRoot, SPARK_DATASOURCE_DIR);
     if (!sparkDatasourceDir.exists()) {
-      fail("Could not locate hudi-spark-datasource directory");
-      return classes;
+      fail("Could not locate " + SPARK_DATASOURCE_DIR + " directory");
+      return classesByModule;
     }
 
     // Look for Scala test directories in all submodules
@@ -127,12 +331,15 @@ public class TestSparkSqlHudiPackageStructure {
       for (File submodule : submodules) {
         File scalaTestDir = new File(submodule, "src/test/scala/" + 
PACKAGE_PATH);
         if (scalaTestDir.exists() && scalaTestDir.isDirectory()) {
-          classes.addAll(findScalaFilesRecursively(scalaTestDir, 
BASE_PACKAGE));
+          List<String> classes = findScalaFilesRecursively(scalaTestDir, 
BASE_PACKAGE);
+          if (!classes.isEmpty()) {
+            classesByModule.put(SPARK_DATASOURCE_DIR + "/" + 
submodule.getName(), classes);
+          }
         }
       }
     }
 
-    return classes;
+    return classesByModule;
   }
 
   /**
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
index 007fbd26ca79..9c88590cb72e 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
@@ -24,6 +24,7 @@ import org.apache.hudi.common.fs.FSUtils
 import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType
 import org.apache.hudi.common.schema.HoodieSchema
 import org.apache.hudi.common.schema.internal.HoodieSchemaException
+import org.apache.hudi.common.table.TableSchemaResolver
 import org.apache.hudi.common.testutils.HoodieTestUtils
 import org.apache.hudi.common.util.StringUtils
 import org.apache.hudi.testutils.DataSourceTestUtils
@@ -977,6 +978,107 @@ class TestVariantDataType extends HoodieSparkSqlTestBase {
     }
   }
 
+  test("Test Spark 3.x schema-on-read reads of a variant table with a 
committed internal schema") {
+    // #18021: hoodie.schema.on.read.enable resolves the schema through the 
InternalSchema round
+    // trip, whose sentinel detection restores the VARIANT logical type - the 
exact input
+    // HoodieSparkSchemaConverters rejects on Spark 3.x. Verified 2026-08-20: 
every leg fails
+    // LOUDLY with the same actionable error as the plain auto-resolve path; 
there is no silent
+    // wrong data and no obscure secondary failure. Notably that includes the 
documented
+    // struct-DDL compat mode, which works on this same table with the conf 
off (pinned below)
+    // but breaks once it is on, because internal-schema resolution overrides 
the user's DDL.
+    // Real support is #18285; until then Spark 3.x compat-mode readers must 
keep
+    // hoodie.schema.on.read.enable off - necessary, but only sufficient off a 
Hive catalog:
+    // DefaultSource discards the user schema outright when 
isUsingHiveCatalog, so under HMS the
+    // struct DDL below is never seen and the read throws with the conf off 
too. This test runs
+    // on the in-memory catalog, so it pins the non-Hive half only.
+    assume(HoodieSparkUtils.isSpark3, "This test verifies Spark 3.x behavior 
with schema-on-read")
+
+    withTempDir { tmpDir =>
+      
HoodieTestUtils.extractZipToDirectory("variant_backward_compat/variant_schema_on_read_cow.zip",
 tmpDir.toPath, getClass)
+      val tablePath = 
tmpDir.toPath.resolve("variant_schema_on_read_cow").toString
+
+      // HoodieBaseHadoopFsRelationFactory (reached here via
+      // HoodieCopyOnWriteSnapshotHadoopFsRelationFactory, not 
HoodieBaseRelation) swallows an
+      // internal-schema load failure into None and then falls back to 
getTableSchema, which
+      // throws the same exception - so neither auto-resolve leg can, on its 
own, prove the
+      // InternalSchema path ran. This assert only pins that the fixture still 
carries a loadable
+      // internal schema; the conf-off/conf-on catalog-DDL pair below is what 
discriminates,
+      // because with a DDL present the None fallback resolves to the user 
schema and succeeds.
+      val schemaResolver = new TableSchemaResolver(createMetaClient(spark, 
tablePath))
+      assert(schemaResolver.getTableInternalSchemaFromCommitMetadata.isPresent,
+        "fixture must carry a committed internal schema; regenerate it per the 
README")
+
+      def assertVariantRejected(label: String)(f: => Unit): Unit = {
+        val ex = intercept[HoodieSchemaException](f)
+        assert(ex.getCause.getMessage.contains("VARIANT type is only supported 
in Spark 4.0+"),
+          s"[$label] expected the actionable variant rejection, got: 
${ex.getCause}")
+      }
+
+      assertVariantRejected("auto-resolve, plain") {
+        spark.read.format("hudi").load(tablePath).collect()
+      }
+      assertVariantRejected("auto-resolve, schema-on-read") {
+        spark.read.format("hudi").option("hoodie.schema.on.read.enable", 
"true").load(tablePath).collect()
+      }
+
+      val tableName = generateTableName
+      spark.sql(
+        s"""
+           |create table $tableName (
+           |  id int,
+           |  v struct<value: binary, metadata: binary>,
+           |  ts long,
+           |  note string
+           |) using hudi
+           |location '$tablePath'
+           |tblproperties (
+           |  primaryKey = 'id',
+           |  preCombineField = 'ts'
+           |)
+         """.stripMargin)
+      try {
+        // With the conf off the compat struct DDL works even though the table 
carries an
+        // internal schema - pinning that the conf, not the committed internal 
schema itself,
+        // is what breaks compat mode.
+        val rows = spark.sql(s"select id, v, note from $tableName order by 
id").collect()
+        assert(rows.map(r => (r.getInt(0), r.getString(2))).toSeq == Seq((1, 
null), (2, "n2")),
+          "[compat struct DDL, conf off] expected both rows readable through 
the struct DDL")
+
+        // Pin the variant bytes, not just non-nullness, so a swapped or 
structurally wrong
+        // value/metadata pair cannot pass as "no silent wrong data". The DDL 
orders the struct
+        // as (value, metadata); Spark resolves the parquet fields by name.
+        // metadata: v1 header, 1-entry dictionary holding "key" (shared by 
both rows).
+        val expectedMetadata = Array[Byte](0x01, 0x01, 0x00, 0x03, 0x6B, 0x65, 
0x79)
+        // value: 1-field object, field id 0, data offsets 0..3, short-string 
"v1" / "v2".
+        val expectedValues = Map(
+          1 -> Array[Byte](0x02, 0x01, 0x00, 0x00, 0x03, 0x09, 0x76, 0x31),
+          2 -> Array[Byte](0x02, 0x01, 0x00, 0x00, 0x03, 0x09, 0x76, 0x32))
+        rows.foreach { row =>
+          val id = row.getInt(0)
+          assert(!row.isNullAt(1), s"[compat struct DDL, conf off] variant 
column null for id=$id")
+          val variant = row.getStruct(1)
+          assert(variant.size == 2, s"[compat struct DDL, conf off] expected 
(value, metadata) for id=$id")
+          val valueBytes = variant.getAs[Array[Byte]](0)
+          val metadataBytes = variant.getAs[Array[Byte]](1)
+          assert(valueBytes.sameElements(expectedValues(id)),
+            s"[compat struct DDL, conf off] variant value bytes mismatch for 
id=$id, got: "
+              + valueBytes.map("0x%02X".format(_)).mkString(", "))
+          assert(metadataBytes.sameElements(expectedMetadata),
+            s"[compat struct DDL, conf off] variant metadata bytes mismatch 
for id=$id, got: "
+              + metadataBytes.map("0x%02X".format(_)).mkString(", "))
+        }
+
+        assertVariantRejected("compat struct DDL, schema-on-read") {
+          withSQLConf("hoodie.schema.on.read.enable" -> "true") {
+            spark.sql(s"select id, v, note from $tableName order by 
id").collect()
+          }
+        }
+      } finally {
+        spark.sql(s"drop table $tableName")
+      }
+    }
+  }
+
   test(s"Test Backward Compatibility: Read Spark 4.0 Variant Table in Spark 
3.x") {
     // This test only runs on Spark 3.x to verify backward compatibility
     assume(HoodieSparkUtils.isSpark3, "This test verifies Spark 3.x can read 
Spark 4.0 Variant tables")

Reply via email to