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


##########
hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringOperator.java:
##########
@@ -175,6 +177,13 @@ public void open() throws Exception {
     // scan successfully without schema validating exception.
     this.readerSchema = HoodieSchemaUtils.asNullable(schema);
 
+    if (this.sortClusteringEnabled) {

Review Comment:
   Done in 5efe1ab: the check runs in doClustering, ahead of the readers, and 
open() no longer validates. Moving it to open() was not meant to widen the 
blast radius: a bad sort column fails the clustering task, which in async mode 
becomes a failed commit event and a rollback of the instant, as the per-record 
failure did before. testSortClusteringRejectsUnsortableColumnInsideTheTask pins 
that open() succeeds and the plan yields a failed event naming the column.



##########
hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/clustering/ClusteringOperator.java:
##########
@@ -175,6 +177,13 @@ public void open() throws Exception {
     // scan successfully without schema validating exception.
     this.readerSchema = HoodieSchemaUtils.asNullable(schema);
 
+    if (this.sortClusteringEnabled) {
+      // Reject a MAP or VARIANT sort column here, once and by name, as the 
Spark and Java
+      // clients do: left alone it reaches SortOperatorGen, whose generated 
comparator throws
+      // "Unsupported sort field value type" per record inside the sorter.
+      SortUtils.validateSortableColumns(sortColumns(), schema);

Review Comment:
   Done in 5efe1ab: SortOperatorGen's constructor now rejects any sort field 
whose LogicalType root has no typed branch in compareExpression -- ROW, ARRAY, 
MAP, MULTISET, RAW, VARIANT -- by name and type, so BLOB and VECTOR are caught 
as well. That replaces the shared SortUtils check on the Flink side, and since 
every Flink sort route builds a SortOperatorGen, bulk insert gets the same 
guard. testRejectsFieldTheSorterCannotOrder covers ROW, ARRAY and MAP.



##########
hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/HiveHoodieReaderContext.java:
##########
@@ -148,6 +152,35 @@ private ClosableIterator<ArrayWritable> 
getFileRecordIterator(StoragePath filePa
       fileSchema = dataSchema;
     }
 
+    // Fail fast on shredded variant columns: this reader hands the file to a 
plain
+    // parquet-avro read at the requested {metadata, value} projection, so a 
file whose variant
+    // group carries typed_value would come back with silent nulls (the typed 
rows keep their
+    // payload in typed_value, which the projection drops). Detection is 
shape-based on the
+    // footer schema and anchored on the requested column being a variant, so 
plain user structs
+    // of the same shape are left alone. toShreddedReadSchema recurses through 
structs, array
+    // elements and map values, matching the row writer, which shreds nested 
variants too.
+    // Columns not requested (e.g. count(*)) stay readable, and so does a read 
whose nested column
+    // paths (hive.io.file.readNestedColumn.paths) all miss the shredded 
group: Hive's parquet
+    // reader materializes only the paths it is given, and the mask rewrite 
below already handles
+    // the compacted projection such a read comes back in.
+    if (isParquetOrOrc && requiredSchema.getType() == HoodieSchemaType.RECORD) 
{
+      HoodieSchema shreddedReadSchema = 
VariantSchemaUtils.toShreddedReadSchema(requiredSchema, fileSchema);
+      if (shreddedReadSchema != requiredSchema) {
+        List<String> shreddedPaths = new ArrayList<>();
+        collectShreddedVariantPaths(requiredSchema, shreddedReadSchema, "", 
shreddedPaths);
+        List<String> offendingColumns = 
HoodieColumnProjectionUtils.columnsReadingShreddedPaths(

Review Comment:
   Done in 5efe1ab: the flagged columns are intersected with 
hive.io.file.readcolumn.names off the same conf, lower-cased, so a CUSTOM 
merge's whole-table required schema no longer fails `select id`; empty names 
(count(*)) flag nothing, the legacy guard's reading of that conf. 
getFileRecordIteratorFlagsOnlyColumnsHiveReads pins `select *` failing, `select 
id` reaching the record reader with the whole table required, and count(*) 
reading; the existing legs now set the names Hive would.



##########
hudi-hadoop-mr/src/main/java/org/apache/hudi/hadoop/HoodieParquetInputFormat.java:
##########
@@ -233,4 +268,266 @@ private RecordReader<NullWritable, ArrayWritable> 
createBootstrappingRecordReade
           true);
     }
   }
-}
\ No newline at end of file
+
+  /**
+   * The file-group-reader path fails fast on shredded variant reads inside
+   * HiveHoodieReaderContext, but a split can bypass it three ways (see
+   * HoodieInputFormatUtils.shouldUseFilegroupReader): the file group reader 
disabled,
+   * schema-on-read enabled, and bootstrap splits. Those land on Hive's plain 
parquet reader at
+   * the synced {metadata, value} projection, which silently nulls typed_value 
- so repeat the
+   * fail-fast for them. Only reads that request a column holding a shredded 
variant fail;
+   * count(*) and projections that skip the variant keep working. The footer 
read is gated on a
+   * requested column whose synced Hive type embeds the variant {metadata, 
value} shape, so
+   * non-variant tables never pay it: the raw columns.types string is screened 
for the shape's
+   * marker before it is parsed, keeping the type parse itself off every other 
table's splits.
+   *
+   * <p>The footer read here is in addition to the one Hive's 
ParquetRecordReaderBase.getSplit
+   * performs right after, so a variant table pays one extra footer read per 
legacy-path split that
+   * requests the variant column; non-variant tables are screened out before 
it.
+   *
+   * <p>The footer's MessageType is inspected directly, without converting it 
to Avro:
+   * AvroSchemaConverterWithTimestampNTZ.convertINT96 throws unless 
parquet.avro.readInt96AsFixed
+   * is set (nothing in Hudi sets it), and Spark writes timestamps as INT96 by 
default, so the
+   * conversion would fail the very reads this guard is careful to leave 
working. The footer
+   * carries no variant logical type either way, so its groups are matched by 
shape at any depth,
+   * and only at a path where the requested column's parsed Hive type declares 
a variant node.
+   *
+   * <p>Hive's read column names are top-level only, so a requested struct 
column does not imply
+   * its whole interior: nested column pruning is carried separately as dotted 
paths
+   * (hive.io.file.readNestedColumn.paths), which
+   * {@link HoodieColumnProjectionUtils#columnsReadingShreddedPaths} applies 
for both this guard
+   * and its file-group-reader sibling in HiveHoodieReaderContext.
+   *
+   * <p>The guard is best-effort throughout: a malformed columns/columns.types 
pairing, an
+   * unparseable type string, or a projection that names no column all fall 
through to the plain
+   * parquet reader rather than failing a read it would have served.
+   */
+  @VisibleForTesting
+  static void validateNoShreddedVariantRead(InputSplit split, JobConf job) {
+    if (!(split instanceof FileSplit)) {
+      return;
+    }
+    Path filePath = ((FileSplit) split).getPath();
+    // A native parquet log file name ends in .parquet too. The realtime path 
never hands a log-only
+    // split here today, so the second clause only makes the intent explicit.
+    if 
(!filePath.getName().endsWith(HoodieFileFormat.PARQUET.getFileExtension())
+        || FSUtils.isLogFile(filePath.getName())) {
+      return;
+    }
+    // Screen the raw type string before anything parses it: the TypeInfoUtils 
parse below is a
+    // cost every legacy-path split of every table would otherwise pay. Only 
the variant shape's
+    // marker earns the parse; the exact anchor check on the parsed types is 
below.
+    String rawIoColumnTypes = 
WHITESPACE.matcher(job.get(IOConstants.COLUMNS_TYPES, ""))
+        .replaceAll("")
+        .toLowerCase(Locale.ROOT);
+    if (!rawIoColumnTypes.contains(HIVE_VARIANT_SHAPE_MARKER)) {
+      return;
+    }
+    Set<String> requestedColumns = 
Arrays.stream(HoodieColumnProjectionUtils.getReadColumnNames(job))
+        .map(name -> name.toLowerCase(Locale.ROOT))
+        .collect(Collectors.toSet());
+    if (requestedColumns.isEmpty()) {
+      // Hive writes the FULL column-name list for `select *`: 
HiveInputFormat.pushProjection
+      // fills in every table column with read.all.columns=false. 
setReadAllColumns is only
+      // called by ProjectionPusher, on the JobConf it clones downstream of 
getRecordReader, so
+      // that flag never reaches the conf seen here (verified in hive-exec 
2.3.10, 3.1.3, 4.0.1).
+      // Empty names here therefore means a read that materializes no column 
(count(*)) or a
+      // caller that never projected; read.all.columns, true when untouched, 
is not a signal.
+      return;
+    }
+    List<String> ioColumns = HoodieColumnProjectionUtils.getIOColumns(job);
+    List<TypeInfo> ioColumnTypes;
+    try {
+      ioColumnTypes = 
TypeInfoUtils.getTypeInfosFromTypeString(job.get(IOConstants.COLUMNS_TYPES, 
""));
+    } catch (RuntimeException e) {
+      // The screen above strips whitespace and lower-cases; the TypeInfoUtils 
parse tolerates
+      // neither, so a string it lets through can still fail to parse. Bail 
out like the pairing
+      // check below rather than failing a read the plain parquet reader would 
serve.
+      LOG.debug("Skipping the shredded variant guard for {}: {} did not 
parse", filePath, IOConstants.COLUMNS_TYPES, e);
+      return;
+    }
+    if (ioColumns.size() != ioColumnTypes.size()) {
+      // The guard is best-effort: a malformed columns/columns.types pairing 
must not fail
+      // reads the plain parquet reader would otherwise serve.
+      return;
+    }
+    // The anchor, per requested column: the Hive-form paths of every node 
whose synced type is the
+    // exact node shape struct<metadata:binary,value:binary> that 
HiveSchemaUtil.convertField emits
+    // for a VARIANT (HMS and Glue sync both pass doFormat=false, so no spaces 
and no backticks).
+    // A struct carrying any further member is a plain user struct that 
happens to hold those two,
+    // and is exempt here as it is in the sibling Spark guards. One route 
fails open:
+    // TableSchemaResolver's footer fallback (see the comment near 
TableSchemaResolver:118) strips
+    // shredding by shape at the top level only, so a variant shredded below 
the top level can
+    // reach the metastore with typed_value still in its synced type; that 
three-member struct
+    // reads as a user struct here, i.e. pre-PR behaviour.
+    Map<String, List<String>> variantPathsByColumn = new HashMap<>();
+    for (int i = 0; i < ioColumns.size(); i++) {
+      String columnName = ioColumns.get(i).toLowerCase(Locale.ROOT);
+      if (!requestedColumns.contains(columnName)) {
+        continue;
+      }
+      List<String> variantPaths = new ArrayList<>();
+      collectHiveVariantPaths(ioColumnTypes.get(i), columnName, variantPaths);
+      if (!variantPaths.isEmpty()) {
+        variantPathsByColumn.put(columnName, variantPaths);
+      }
+    }
+    if (variantPathsByColumn.isEmpty()) {
+      return;
+    }
+    StoragePath storagePath = convertToStoragePath(filePath);
+    HoodieStorage storage = HoodieStorageUtils.getStorage(storagePath, 
HadoopFSUtils.getStorageConf(job));
+    MessageType fileSchema = new ParquetUtils().readMessageType(storage, 
storagePath);
+    // The shredded groups the file holds at a path where the column's Hive 
type declares a variant:
+    // the two sides are matched, so neither a file group of that shape under 
a user struct nor a
+    // synced variant the file does not actually shred can flag the column on 
its own.
+    List<String> shreddedPaths = new ArrayList<>();
+    for (Type field : fileSchema.getFields()) {
+      String columnName = field.getName().toLowerCase(Locale.ROOT);
+      List<String> variantPaths = variantPathsByColumn.get(columnName);
+      if (variantPaths == null) {
+        continue;
+      }
+      List<String> filePaths = new ArrayList<>();
+      collectShreddedVariantPaths(field, columnName, filePaths);
+      
filePaths.stream().filter(variantPaths::contains).forEach(shreddedPaths::add);
+    }
+    List<String> offendingColumns = 
HoodieColumnProjectionUtils.columnsReadingShreddedPaths(job, shreddedPaths);
+    if (!offendingColumns.isEmpty()) {
+      throw new HoodieException(String.format(
+          "Column(s) '%s' of %s hold a shredded variant (typed_value present); 
the Hive reader "
+              + "cannot reconstruct shredded variants. Read the table with 
Spark 4.1+, or "
+              + "rewrite it unshredded (e.g. cluster with "
+              + "hoodie.parquet.variant.write.shredding.enabled=false).",
+          String.join(", ", offendingColumns), filePath));
+    }
+  }
+
+  /**
+   * Collects into {@code variantPaths} the Hive-form dotted path of every 
node at or beneath
+   * {@code type} whose Hive type is the synced variant shape (see {@link 
#isVariantShapedStruct}),
+   * starting at {@code path}. Only struct members add a segment: Hive 
truncates its nested column
+   * paths at a LIST or MAP column, so a list element and a map value share 
their column's path.
+   */
+  private static void collectHiveVariantPaths(TypeInfo type, String path, 
List<String> variantPaths) {
+    switch (type.getCategory()) {
+      case STRUCT: {
+        StructTypeInfo struct = (StructTypeInfo) type;
+        if (isVariantShapedStruct(struct)) {
+          variantPaths.add(path);
+          return;
+        }
+        List<String> memberNames = struct.getAllStructFieldNames();
+        List<TypeInfo> memberTypes = struct.getAllStructFieldTypeInfos();
+        for (int i = 0; i < memberNames.size(); i++) {
+          collectHiveVariantPaths(memberTypes.get(i), path + "." + 
memberNames.get(i).toLowerCase(Locale.ROOT), variantPaths);
+        }
+        break;
+      }
+      case LIST:
+        collectHiveVariantPaths(((ListTypeInfo) 
type).getListElementTypeInfo(), path, variantPaths);
+        break;
+      case MAP:
+        collectHiveVariantPaths(((MapTypeInfo) type).getMapValueTypeInfo(), 
path, variantPaths);
+        break;
+      default:
+        break;
+    }
+  }
+
+  /**
+   * Whether {@code struct} is the Hive type a synced VARIANT gets: exactly 
the two binary members
+   * {@code metadata} and {@code value}. A struct with a third member is a 
user struct - including
+   * one whose third member is named typed_value, which only the footer 
fallback of
+   * TableSchemaResolver can produce for a nested shredded variant.
+   */
+  private static boolean isVariantShapedStruct(StructTypeInfo struct) {
+    List<String> memberNames = struct.getAllStructFieldNames();
+    if (memberNames.size() != 2) {
+      return false;
+    }
+    List<String> lowered = memberNames.stream().map(name -> 
name.toLowerCase(Locale.ROOT)).collect(Collectors.toList());
+    return lowered.contains(HoodieSchema.Variant.VARIANT_METADATA_FIELD)
+        && lowered.contains(HoodieSchema.Variant.VARIANT_VALUE_FIELD)
+        && 
struct.getAllStructFieldTypeInfos().stream().allMatch(HoodieParquetInputFormat::isBinary);
+  }
+
+  /** Whether {@code type} is the Hive {@code binary} primitive. */
+  private static boolean isBinary(TypeInfo type) {
+    return type instanceof PrimitiveTypeInfo && 
serdeConstants.BINARY_TYPE_NAME.equals(type.getTypeName());
+  }
+
+  /**
+   * Collects into {@code shreddedPaths} the Hive-form dotted path of every 
shredded variant group
+   * at or beneath {@code type}: a group carrying both {@code typed_value} and 
{@code metadata}.
+   * The shape is checked at every group before descending, so a shredded 
element is recorded at
+   * its collection column's own path on either list layout. The walk stops at 
the first shredded
+   * group on a branch - everything below it belongs to that variant.
+   *
+   * <p>Paths are lower-cased parquet field names joined by "." starting at 
{@code path}, minus the
+   * levels a Hive dotted path never names, because Hive truncates its nested 
column paths at a
+   * LIST or MAP column: the collection's repeated level, the synthetic level 
between a LIST and
+   * its element, and a map entry's key and value. Only struct members append 
a segment. Which
+   * levels those are is decided structurally, by parquet's own 
backward-compatibility rule rather
+   * than by level names, so a struct element's member that happens to be 
called {@code element},
+   * {@code key} or {@code value} keeps its segment: under a LIST group with a 
single child, that
+   * child is the synthetic level only when it is a group with exactly one 
non-repeated field whose
+   * name is neither {@code array} nor exactly {@code <list>_tuple} (the 
3-level layout), and
+   * otherwise it is the element itself (the 2-level layout); under a MAP 
group the single child is
+   * always the entry level, whose key and value both carry the map's own path.
+   *
+   * <p>LIST and MAP are read off OriginalType rather than the 
LogicalTypeAnnotation that replaced
+   * it: parquet 1.11 and later derive one from the other, while this module 
loads inside Hive,
+   * whose bundled parquet can predate the annotation class entirely (the 
reason ParquetAdapter
+   * picks its implementation reflectively). A collection group shaped unlike 
its annotation (no
+   * single child) is walked as a plain struct, the best-effort reading.
+   */
+  private static void collectShreddedVariantPaths(Type type, String path, 
List<String> shreddedPaths) {
+    if (type.isPrimitive()) {
+      return;
+    }
+    GroupType group = type.asGroupType();
+    if (group.containsField(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD)
+        && group.containsField(HoodieSchema.Variant.VARIANT_METADATA_FIELD)) {
+      shreddedPaths.add(path);
+      return;
+    }
+    OriginalType originalType = group.getOriginalType();
+    if (originalType == OriginalType.LIST && group.getFieldCount() == 1) {
+      Type repeated = group.getType(0);
+      Type element = isSyntheticListLevel(repeated, group.getName()) ? 
repeated.asGroupType().getType(0) : repeated;
+      collectShreddedVariantPaths(element, path, shreddedPaths);
+      return;
+    }
+    if (originalType == OriginalType.MAP && group.getFieldCount() == 1 && 
!group.getType(0).isPrimitive()) {

Review Comment:
   Done in 5efe1ab: testLegacyReaderGuardSeesThroughMapEntries writes the map 
fixture, pins its key_value/value layout on the footer, and expects the guard 
to name `m` for the whole column and for the nested path `m`. Without the MAP 
arm the walk collects m.key_value.value, which never meets the Hive-side `m`, 
so the leg fails.



##########
hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala:
##########
@@ -203,4 +212,143 @@ object ParquetSchemaEvolutionUtils {
       internalSchemaOpt
     }
   }
+
+  /**
+   * Fails fast when schema-on-read meets a shredded variant file. The 
internal schema models a
+   * variant as a two-field {metadata, value} record (with sentinel negative 
field ids, see
+   * InternalSchemaConverter), so the merged request clips the file's 
typed_value away and the
+   * typed rows would read back with a null value residual - silent data loss. 
Reconstruction
+   * under schema-on-read is tracked by #18285; until then the read must fail 
loudly. The check
+   * anchors on the sentinel ids, which no real user field can carry, so plain 
user structs of
+   * the same shape are left alone. The walk recurses through structs, arrays 
and maps because
+   * the row writer shreds nested variants too (see VariantSchemaUtils).
+   *
+   * Footer columns are resolved by the query-schema name. A column renamed 
under schema-on-read
+   * still carries its old name in the file and is not matched here; such 
reads are left to
+   * #18285 with reconstruction itself.
+   *
+   * A request in the full-variant projection shape fails fast regardless of 
the file's layout:
+   * the merged internal-schema request materializes the variant as {metadata, 
value} while the
+   * consumer expects the ordinal-named extraction struct, so the read cannot 
be served either
+   * way (pruning treats the rewritten struct as the variant column itself, see
+   * SparkInternalSchemaConverter.isVariantRewriteStruct). Two producers ask 
for that shape: a
+   * query rewritten by Spark's PushVariantIntoScan (4.x), and Hudi's own 
base-file reads on
+   * 4.1+ (SparkFileFormatInternalRowReaderContext, via 
SparkAdapter.buildFullVariantReadSchema)
+   * whenever their reader context carries the table's internal schema - 
SparkReaderContextFactory
+   * puts the table path and valid commits on the conf once one is committed, 
so inline compaction
+   * and clustering under a schema-on-read write, and CDC reads, land here on 
an unshredded

Review Comment:
   Done in 5efe1ab: the sentence names both producers separately -- 
SparkReaderContextFactory for the write-side services (inline compaction and 
clustering), and HoodieFileGroupReaderBasedFileFormat.setSchemaEvolutionConfigs 
query-side, whose conf CDCFileGroupIterator builds its reader context from.



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunClusteringProcedure.scala:
##########
@@ -149,6 +149,20 @@ class RunClusteringProcedure extends BaseProcedure
         logInfo("No options")
     }
 
+    // Normalise once so the plan stores the same trimmed list the strategies 
and partitioners
+    // work from, and validate it up front, before any plan is scheduled - 
whichever of `order`
+    // or `options` set it. A blank value is no sort at all, as the strategies 
read it.
+    confs.get(HoodieClusteringConfig.PLAN_STRATEGY_SORT_COLUMNS.key()).foreach 
{ sortColumns =>
+      val normalized = 
sortColumns.split(",").map(_.trim).filter(_.nonEmpty).mkString(",")
+      if (normalized.isEmpty) {
+        confs = confs - HoodieClusteringConfig.PLAN_STRATEGY_SORT_COLUMNS.key()

Review Comment:
   Done in 5efe1ab: a blank order/options value is stored as "" rather than 
dropped, so it stays the top override in createHoodieWriteClient and a sort 
column from the session conf or table config cannot slip through unvalidated; 
the strategies still read it as no sort. The comment says so, and a test leg 
runs order => '' under a session-conf s.tags and checks the plan carries no 
sort key.



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunClusteringProcedure.scala:
##########
@@ -149,6 +149,20 @@ class RunClusteringProcedure extends BaseProcedure
         logInfo("No options")
     }
 
+    // Normalise once so the plan stores the same trimmed list the strategies 
and partitioners
+    // work from, and validate it up front, before any plan is scheduled - 
whichever of `order`
+    // or `options` set it. A blank value is no sort at all, as the strategies 
read it.
+    confs.get(HoodieClusteringConfig.PLAN_STRATEGY_SORT_COLUMNS.key()).foreach 
{ sortColumns =>
+      val normalized = 
sortColumns.split(",").map(_.trim).filter(_.nonEmpty).mkString(",")
+      if (normalized.isEmpty) {
+        confs = confs - HoodieClusteringConfig.PLAN_STRATEGY_SORT_COLUMNS.key()
+      } else {
+        validateOrderColumns(normalized, metaClient)

Review Comment:
   Done in 5efe1ab: validateOrderColumns resolves against getTableSchema(true) 
and resolves dotted paths with HoodieSchema.getNestedField, checking the leaf 
under its path through a new SortUtils.validateSortableColumn, so 
_hoodie_commit_time and s.level pass on both routes, s.tags (a MAP leaf) is 
rejected as "Sorting by column 's.tags' of type MAP ..." and a missing path as 
"Order column not exist". Test legs cover all four.



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLegacyParquetReadPath.scala:
##########
@@ -509,6 +511,66 @@ class TestLegacyParquetReadPath extends 
HoodieSparkClientTestBase with ScalaAsse
     }
   }
 
+  @Test
+  def testCowSnapshotReadWithSchemaOnReadRejectsShreddedVariant(): Unit = {
+    // The shredded-variant guard is copied into all four 
*LegacyHoodieParquetFileFormat versions,
+    // and buildScan on this relation is the only caller that reaches those 
copies -- DefaultSource
+    // never routes a batch read to them. Reading a shredded variant back 
needs Spark 4.1+
+    // (SPARK-54410), which leaves the 4.1 and 4.2 copies as the ones this 
exercises.
+    assumeTrue(HoodieSparkUtils.gteqSpark4_1, "Shredded variants need Spark 
4.1+ to be read back")
+
+    // Schema-on-read models a variant as a two-field {metadata, value} 
record, so the merged
+    // request clips the file's typed_value away and the typed rows would come 
back with a null
+    // value residual -- silent data loss (#18285). The legacy formats must 
fail loudly instead
+    // (ParquetSchemaEvolutionUtils.validateNoShreddedVariants). Forced 
shredding is what puts a
+    // typed_value group under `v`; without it the file carries the unshredded 
pair and there is
+    // nothing for the guard to reject.
+    val shreddedSchemaOnReadOpts = Map(
+      DataSourceReadOptions.SCHEMA_EVOLUTION_ENABLED.key -> "true",
+      DataSourceWriteOptions.RECONCILE_SCHEMA.key -> "true",
+      "hoodie.parquet.variant.write.shredding.enabled" -> "true",
+      "hoodie.parquet.variant.force.shredding.schema.for.test" -> "a bigint, b 
string")
+
+    // The short name is required: it resolves to the Spark 4 datasource, the 
only one whose
+    // supportsDataType override accepts a VariantType column on write.
+    spark.sql(
+      """select '1' as id, 1L as ts, 'p0' as partition, 
parse_json('{"a":1,"b":"b1"}') as v
+        |union all
+        |select '2' as id, 1L as ts, 'p0' as partition, 
parse_json('{"a":2,"b":"b2"}') as v""".stripMargin)
+      .write.format("hudi")
+      .options(writeOpts ++ shreddedSchemaOnReadOpts)
+      .option(DataSourceWriteOptions.OPERATION.key, 
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL)
+      .mode(SaveMode.Append)
+      .save(basePath)
+
+    val readOpts = Map(DataSourceReadOptions.SCHEMA_EVOLUTION_ENABLED.key -> 
"true")
+    val metaClient = createMetaClient(spark, basePath)
+    assertTrue(BaseFileOnlyRelation(sqlContext, metaClient, 
legacyReadOpts(readOpts), None).hasSchemaOnRead,
+      "The write must have recorded an InternalSchema, otherwise the guard's 
branch is never taken")
+
+    // Only the relation's own buildScan is exercised here, not the 
HadoopFsRelation conversion:
+    // buildScan embeds the query schema into the reader's Hadoop conf 
(HoodieBaseRelation
+    // .embedInternalSchema), whereas the converted relation is read with the 
plain session conf,
+    // so shouldUseInternalSchema is false there and the guard is not on that 
path at all. That is
+    // also why DefaultSource keeps BaseFileOnlyRelation itself under 
schema-on-read.
+    val thrown = assertThrows(classOf[Throwable]) {
+      legacyRelationDf(readOpts).select("v").collect()
+    }
+    val causes = Iterator.iterate(thrown: Throwable)(_.getCause).takeWhile(_ 
!= null).take(10).toSeq
+    assertTrue(causes.exists(c => c.isInstanceOf[HoodieException]
+      && String.valueOf(c.getMessage).contains("shredded variant")),
+      s"Expected the shredded-variant rejection but got: $thrown")
+
+    // The guard's empty-projection carve-out (count(*) reads no column data 
and must keep working)
+    // is pinned on the file-group-reader path by the count(*) legs in 
TestVariantShreddingMixedLayouts,

Review Comment:
   Done in 5efe1ab: a `select count(*)` sits inside the schema-on-read block of 
"Schema-on-read reads of shredded variant files fail fast", after the two 
failing selects. A COW count(*) takes readBaseFile into the parquet reader's 
getHadoopConfClone with the unpruned query schema, so dropping the 
requiredSchema.nonEmpty gate fails it on the shredded file. The 
TestLegacyParquetReadPath sentence now names that leg.



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