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 827644e8699b fix(spark): null-fill a DDL-added variant column (#20136)
827644e8699b is described below
commit 827644e8699b61d319ed156c92b9f0c39df5aedf
Author: voonhous <[email protected]>
AuthorDate: Thu Oct 1 12:25:57 2026 +0800
fix(spark): null-fill a DDL-added variant column (#20136)
* fix(spark): null-fill a DDL-added variant column
Hudi's parquet readers force spark.sql.optimizer.nestedSchemaPruning
.enabled off, so Spark's ParquetReadSupport skips intersectParquetGroups
and a requested column the file does not have stays in the parquet read
schema as a group synthesised from its catalyst type. HoodieParquet
ReadSupport.trimParquetSchema already drops such NESTED fields but kept
a missing TOP-LEVEL one. For a plain column that was harmless:
parquet-mr null-fills it. For a Spark 4.1 variant column requested as
the PushVariantIntoScan projection struct, or as the full-variant struct
of the internal reader, the synthesised group is a plain struct, Spark
still routes it to ParquetVariantConverter, and buildVariantSchema
throws INVALID_VARIANT_SHREDDING_SCHEMA. After ALTER TABLE ADD COLUMNS
(v2 variant) that killed every pushed-down read of a pre-DDL file, the
COW update of such a file group through the SPARK-record merge handle,
and MOR compaction of it.
Drop the missing top-level field for the row-based reader, the way the
nested ones already are: ParquetRowConverter builds its converters per
parquet field and leaves the catalyst columns it never sees null, which
is what Spark's own reader gets from intersectParquetGroups. The
vectorized reader keeps the field, as in Spark, since it null-fills and
matches by position; every Hudi row read constructs this read support
with enableVectorizedReader = false.
TestHoodieParquetReadSupport pins both arms of the trim over a
projection-shaped missing field. TestVariantShreddingMixedLayouts gets
the end-to-end case from the #18285 checklist: a shredded base with v
only, the DDL pinned as an ALTER_SCHEMA commit typing v2 as VARIANT,
reads over the old file alone, an insert opening a second file group
with v2 shredded, an update rewriting both groups on COW and landing as
log blocks on MOR, compaction on MOR, all read on both
pushVariantIntoScan arms with the plan pinned per arm; COW with both
record types, MOR with parquet log blocks and with avro data blocks.
Fixes #20135. Part of #18285.
* fix(spark): drop missing column for all readers
HoodieSparkParquetReader, which reads native parquet log files, is
row-based but builds the read support with enableVectorizedReader =
true, so the flag-gated drop kept a missing top-level column there: a
pushed-down read of a parquet log block written before the ADD COLUMNS
still failed with INVALID_VARIANT_SHREDDING_SCHEMA. No vectorized
reader uses HoodieParquetReadSupport (they pin READ_SUPPORT_CLASS to
Spark's ParquetReadSupport), so the flag protected nothing.
trimParquetSchema now drops a missing top-level field unconditionally,
like a missing nested one, and the dropMissingTopLevelFields parameter
is gone. The MOR legs of the DDL test write a log block before the DDL
so the old file group holds a log file without the column too.
* test(variant): tighten the DDL-added column test
- Pin the empty message a request trims to when its only field is
missing from the file.
- Build each leg's table once, with the rule on, and sweep only the
reads over both pushVariantIntoScan arms.
- Drop the AVRO record type from the COW leg: its reads repeat the
SPARK leg and the AVRO merge handle passed before the fix (#20135).
- Compare the DDL instant with the one captured right before the
DDL, so the check can fail on the MOR legs again.
- One v2 filter per file or block that holds a value, plus one that
matches nothing, instead of one per id.
- Remove an always-true assertion and one that the unchanged file
listing already covers.
- Say that the file group reader, not every Hudi reader, switches
nested schema pruning off.
---
.../parquet/HoodieParquetReadSupport.scala | 17 +-
.../parquet/TestHoodieParquetReadSupport.scala | 36 ++++
.../schema/TestVariantShreddingMixedLayouts.scala | 234 ++++++++++++++++++++-
3 files changed, 284 insertions(+), 3 deletions(-)
diff --git
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieParquetReadSupport.scala
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieParquetReadSupport.scala
index c63ec0eef9e4..a164d09838a9 100644
---
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieParquetReadSupport.scala
+++
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieParquetReadSupport.scala
@@ -60,16 +60,29 @@ object HoodieParquetReadSupport {
* schema is trimmed down to the requested fields. This can happen when the
table schema evolves and only a subset of
* the nested fields are required by the query.
*
+ * A top-level field the file does not have at all (a column added by DDL,
or by a later write) is
+ * removed as well. ParquetRowConverter builds its converters per parquet
field and leaves the
+ * catalyst columns it never sees null, which is what Spark's own row-based
reader gets from
+ * intersectParquetGroups when nested schema pruning is on (the file group
reader switches it
+ * off). Kept, the field reaches the converter as a group synthesised from
the catalyst type, and
+ * for a Spark 4.1 variant requested as the PushVariantIntoScan projection
struct that is a plain
+ * group the variant converter rejects (INVALID_VARIANT_SHREDDING_SCHEMA,
#20135).
+ *
+ * This holds whatever enableVectorizedReader the read support was built
with: every reader that
+ * uses it is row-based, HoodieSparkParquetReader included although it
passes true, and the
+ * vectorized readers pin READ_SUPPORT_CLASS to Spark's own
ParquetReadSupport.
+ *
* @param requestedSchema the initial parquet schema requested by Spark
* @param fileSchema the actual parquet schema of the file
- * @return a potentially updated schema with empty struct fields removed
+ * @return a potentially updated schema with missing fields and empty struct
fields removed
*/
def trimParquetSchema(requestedSchema: MessageType, fileSchema:
MessageType): MessageType = {
val trimmedFields = requestedSchema.getFields.asScala.map(field => {
if (fileSchema.containsField(field.getName)) {
trimParquetType(field, fileSchema.asGroupType().getType(field.getName))
} else {
- Some(field)
+ // Not in the file: left out of the read schema, and the row converter
null-fills it.
+ None
}
}).filter(_.isDefined).map(_.get).toArray[Type]
Types.buildMessage().addFields(trimmedFields:
_*).named(requestedSchema.getName)
diff --git
a/hudi-client/hudi-spark-client/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestHoodieParquetReadSupport.scala
b/hudi-client/hudi-spark-client/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestHoodieParquetReadSupport.scala
index 973454d1b70c..6461c9fe853f 100644
---
a/hudi-client/hudi-spark-client/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestHoodieParquetReadSupport.scala
+++
b/hudi-client/hudi-spark-client/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestHoodieParquetReadSupport.scala
@@ -113,4 +113,40 @@ class TestHoodieParquetReadSupport {
.named("required")
Assertions.assertEquals(expectedSchema, trimmedSchema)
}
+
+ /**
+ * A top-level field the file does not have at all (a column added by DDL,
say) is dropped from
+ * the read schema; the row converter leaves the catalyst column it never
sees null. The missing
+ * field is shaped like the Spark 4.1 PushVariantIntoScan projection struct
of a variant column,
+ * the case that fails when it is kept (#20135): the synthesised group is
not a variant group,
+ * yet the variant converter is built over it. A request left with no field
at all trims to an
+ * empty message.
+ */
+ @Test
+ def testSchemaTrimmingMissingTopLevelField(): Unit = {
+ val projectionStruct = Types.optionalGroup()
+ .addField(Types.optional(PrimitiveTypeName.BINARY).named("0"))
+ .named("v2")
+ val requiredSchema = Types.buildMessage()
+ .addField(Types.required(PrimitiveTypeName.INT32).named("id"))
+ .addField(Types.optional(PrimitiveTypeName.BINARY).named("v"))
+ .addField(projectionStruct)
+ .named("required")
+ val dataSchema = Types.buildMessage()
+ .addField(Types.required(PrimitiveTypeName.INT32).named("id"))
+ .addField(Types.optional(PrimitiveTypeName.BINARY).named("v"))
+ .named("data")
+
+ val expectedSchema = Types.buildMessage()
+ .addField(Types.required(PrimitiveTypeName.INT32).named("id"))
+ .addField(Types.optional(PrimitiveTypeName.BINARY).named("v"))
+ .named("required")
+ Assertions.assertEquals(expectedSchema,
HoodieParquetReadSupport.trimParquetSchema(requiredSchema, dataSchema))
+
+ // A request whose only field is missing trims to an empty message, and
the reader returns
+ // rows of nulls for it.
+ val onlyMissingField =
Types.buildMessage().addField(projectionStruct).named("required")
+ Assertions.assertEquals(Types.buildMessage().named("required"),
+ HoodieParquetReadSupport.trimParquetSchema(onlyMissingField, dataSchema))
+ }
}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
index 671840ca5ce5..544b01db4e25 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
@@ -22,7 +22,7 @@ package org.apache.spark.sql.hudi.dml.schema
import org.apache.hudi.{DataSourceReadOptions, DataSourceWriteOptions,
DefaultSparkRecordMerger, HoodieSchemaConversionUtils, HoodieSparkUtils,
HoodieTableSchema, SparkAdapterSupport}
import org.apache.hudi.common.config.HoodieMetadataConfig
import org.apache.hudi.common.fs.FSUtils
-import org.apache.hudi.common.model.{HoodieAvroRecordMerger, HoodieFileFormat}
+import org.apache.hudi.common.model.{HoodieAvroRecordMerger,
HoodieCommitMetadata, HoodieFileFormat, WriteOperationType}
import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType
import org.apache.hudi.common.schema.{HoodieSchema, HoodieSchemaField,
HoodieSchemaType}
import org.apache.hudi.common.table.log.block.HoodieLogBlock.HoodieLogBlockType
@@ -30,6 +30,7 @@ import org.apache.hudi.config.{HoodieBootstrapConfig,
HoodieWriteConfig}
import org.apache.hudi.core.io.storage.VariantShreddingInferenceFileWriter
import org.apache.hudi.keygen.NonpartitionedKeyGenerator
import org.apache.hudi.testutils.DataSourceTestUtils
+import org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient
import org.apache.hadoop.fs.{Path => HadoopPath}
import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName
@@ -2363,6 +2364,237 @@ class TestVariantShreddingMixedLayouts extends
HoodieSparkSqlTestBase with Varia
}
}
+ test("A second variant column added by DDL reads through old and new files")
{
+ assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
+
+ // Without hoodie.schema.on.read.enable, `alter table add columns` takes
the v1 route:
+ // HoodieCatalog.loadTable hands back a V1Table, Spark plans
AlterTableAddColumnsCommand, and
+ // HoodiePostAnalysisRule swaps in AlterHoodieTableAddColumnsCommand,
which writes an EMPTY
+ // ALTER_SCHEMA commit carrying the new schema and touches no data file. A
VARIANT column added
+ // that way is its own case: the table has v2 before any file does, so
every read of an old
+ // file has to yield a null v2 and, with PushVariantIntoScan on, a null
projection struct that
+ // variant_get turns into null rather than an error. Hudi's readers switch
Spark's nested schema
+ // pruning off, so the missing column has to leave the parquet read schema
through
+ // HoodieParquetReadSupport.trimParquetSchema; kept there it becomes a
plain group over which
+ // Spark builds its variant converter and fails
(INVALID_VARIANT_SHREDDING_SCHEMA, #20135). The
+ // same base-file read sits under the COW merge handle and under MOR
compaction, and the MOR
+ // legs write a log block before the DDL as well: a native parquet log
without v2 goes through
+ // the same read support from HoodieSparkParquetReader. New files shred v2
under the forced
+ // layout (which applies to every variant column), a COW update rewrites
an old file group with
+ // v2 added, and on MOR a log block carrying v2 merges over a base file
and a log block without
+ // it until compaction folds them together.
+ def rowsSql(lo: Int, hi: Int, kPrefix: String, ts: Long, v2Prefix:
Option[String]): String = {
+ val v2Col = v2Prefix.map(p => s""", parse_json(concat('{"k":"$p', id,
'"}')) as v2""").getOrElse("")
+ s"""select cast(id as int) as id,
+ | parse_json(concat('{"k":"$kPrefix', id, '"}')) as v,
+ | ${ts}L as ts$v2Col from range($lo, $hi, 1, 1)""".stripMargin
+ }
+ // ids 0-2 come from commit 1 (on MOR through the log block written before
the DDL), ids 3-5
+ // from the insert after the DDL.
+ def vOf(id: Int): String = if (id < 3) s"a$id" else s"b$id"
+
+ // The reads every state is checked with, on both pushVariantIntoScan
arms; `v2Of` gives the
+ // expected $.k of v2 per id, null where the row has no v2. `v2FilterIds`
names one row per
+ // file or block that holds a non-null v2, each with its own filter, so a
slot that reads back
+ // through the wrong file or block cannot hide behind the others.
+ def assertReads(tableName: String, leg: String, ids: Range, v2Of: Int =>
String,
+ v2FilterIds: Seq[Int] = Seq.empty): Unit = {
+ Seq("true", "false").foreach { pushIntoScan =>
+ withSQLConf("spark.sql.variant.pushVariantIntoScan" -> pushIntoScan) {
+ checkAnswer(s"select id, variant_get(v, '$$.k', 'string'),
variant_get(v2, '$$.k', 'string') " +
+ s"from $tableName order by id")(ids.map(id => Seq(id, vOf(id),
v2Of(id))): _*)
+ checkAnswer(s"select id from $tableName where v2 is null order by
id")(
+ ids.filter(v2Of(_) == null).map(Seq(_)): _*)
+ checkAnswer(s"select id from $tableName where variant_get(v, '$$.k',
'string') = 'a1'")(Seq(1))
+ // A v2 filter that matches no row, so a state without any v2 value
filters on v2 too.
+ checkAnswer(s"select id from $tableName where variant_get(v2,
'$$.k', 'string') = 'none'")()
+ v2FilterIds.foreach { id =>
+ checkAnswer(
+ s"select id from $tableName where variant_get(v2, '$$.k',
'string') = '${v2Of(id)}'")(Seq(id))
+ }
+ checkAnswer(s"select id, cast(v2 as string) from $tableName order by
id")(
+ ids.map(id => Seq(id, Option(v2Of(id)).map(k =>
s"""{"k":"$k"}""").orNull)): _*)
+ // Both arms expect the very same rows; only the plan tells them
apart.
+ val pushed = pushIntoScan.toBoolean
+ val verdict = if (pushed) "should have" else "must not have"
+ Seq("v", "v2").foreach { column =>
+ assert(variantProjectionPushedIntoScan(
+ s"select id, variant_get($column, '$$.k', 'string') from
$tableName") == pushed,
+ s"[$leg, pushVariantIntoScan=$pushIntoScan] PushVariantIntoScan
$verdict rewritten $column " +
+ "into a projection struct")
+ }
+ }
+ }
+ }
+
+ def runLeg(label: String, tableType: String, recordTypes:
Seq[HoodieRecordType], tableProps: Seq[String],
+ expectLogBlockType: Option[HoodieLogBlockType]): Unit = {
+ // The table is built once, with the rule on as it is by default on
Spark 4.1+; only the reads
+ // are swept over both arms (assertReads).
+ withSQLConf("spark.sql.variant.pushVariantIntoScan" -> "true") {
+ withVariantTable(label, tableType, props = tableProps,
+ recordTypes = recordTypes) { (tableName, tablePath, leg) =>
+ val isMor = expectLogBlockType.isDefined
+
+ // Commit 1: v only, shredded. The MOR legs seed values that the log
block below
+ // replaces, so a merged read that skipped that block would show the
stale ones.
+ withWriteLayout(Forced("k string")) {
+ spark.sql(s"insert into $tableName ${rowsSql(0, 3, if (isMor) "o"
else "a", 1000L, None)}")
+ }
+ assertVariantLayout(tablePath, shredded = true, leg)
+ val commit1 = latestCompletedInstant(tablePath)
+ val firstFileIds = baseLayouts(tablePath).filter(_.instantTime ==
commit1).map(_.fileId).distinct
+ assert(firstFileIds.size == 1, s"[$leg] commit 1 should write one
file group, got $firstFileIds")
+ val firstFileId = firstFileIds.head
+
+ // MOR: a log block written before the DDL, so the old file group
reads v2 through a log
+ // file that lacks the column as well as through a base file that
lacks it.
+ val preDdlLogInstant = if (isMor) {
+ withWriteLayout(Forced("k string")) {
+ spark.sql(s"update $tableName set " +
+ """v = parse_json(concat('{"k":"a', id, '"}')), ts = 1001""")
+ }
+ Some(latestCompletedInstant(tablePath))
+ } else {
+ None
+ }
+ val filesBeforeDdl = listDataParquetFiles(tablePath).sorted
+ val instantBeforeDdl = latestCompletedInstant(tablePath)
+
+ // The DDL: an empty ALTER_SCHEMA commit whose metadata carries v2
as a VARIANT.
+ spark.sql(s"alter table $tableName add columns (v2 variant)")
+ val alterInstant = latestCompletedInstant(tablePath)
+ assert(alterInstant != instantBeforeDdl, s"[$leg] the DDL should
have completed its own commit")
+ val metaClient = createMetaClient(spark, tablePath)
+ val alterMetadata = metaClient.getActiveTimeline.readCommitMetadata(
+
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants.lastInstant.get)
+ assert(alterMetadata.getOperationType ==
WriteOperationType.ALTER_SCHEMA,
+ s"[$leg] the DDL commit should be ALTER_SCHEMA, got
${alterMetadata.getOperationType}")
+ assert(listDataParquetFiles(tablePath).sorted == filesBeforeDdl,
+ s"[$leg] the DDL must not add or rewrite data files")
+ val committedV2 =
HoodieSchema.parse(alterMetadata.getMetadata(HoodieCommitMetadata.SCHEMA_KEY))
+ .getField("v2").get.schema().getNonNullType.getType
+ assert(committedV2 == HoodieSchemaType.VARIANT,
+ s"[$leg] the committed table schema should carry v2 as a VARIANT,
got $committedV2")
+ val catalogSchema = spark.table(tableName).schema
+ assert(catalogSchema("v2").dataType.simpleString == "variant",
+ s"[$leg] the catalog should report v2 as variant, got
${catalogSchema("v2").dataType}")
+ val dataColumns =
catalogSchema.fieldNames.filterNot(_.startsWith("_hoodie")).toSeq
+ assert(dataColumns == Seq("id", "v", "ts", "v2"),
+ s"[$leg] the DDL appends v2 after the data columns, got
$dataColumns")
+
+ // Old files only: every row reads v2 through a file that lacks the
column.
+ assertReads(tableName, leg, 0 until 3, _ => null)
+
+ // Commit 3: rows that carry v2. The small-file limit of 0 keeps
them out of the old
+ // file group, so they land in a second file group whose base file
has v2.
+ withSQLConf("hoodie.parquet.small.file.limit" -> "0") {
+ withWriteLayout(Forced("k string")) {
+ spark.sql(s"insert into $tableName ${rowsSql(3, 6, "b", 1000L,
Some("z"))}")
+ }
+ }
+ val commit3 = latestCompletedInstant(tablePath)
+ val v2Bases = baseLayouts(tablePath, "v2")
+ assert(v2Bases.map(_.fileId).distinct.size == 2,
+ s"[$leg] expected the old and the new file group, got
${v2Bases.map(_.path)}")
+ val oldBase = v2Bases.filter(_.instantTime == commit1)
+ assert(oldBase.size == 1 && !oldBase.head.hasColumn,
+ s"[$leg] the commit-1 base file must not have v2:
${oldBase.map(_.path)}")
+ val newBase = v2Bases.filter(_.instantTime == commit3)
+ assert(newBase.size == 1 && newBase.head.hasColumn &&
newBase.head.typedFields.contains("k"),
+ s"[$leg] the commit-3 base file should carry v2 shredded on k:
${newBase.map(_.path)}")
+ assertAllShredded(baseLayouts(tablePath), shredded = true, s"$leg v
in both base files")
+
+ // id 3 stands for the one slot that holds a v2 value, the new base
file.
+ assertReads(tableName, leg, 0 until 6, id => if (id < 3) null else
s"z$id", v2FilterIds = Seq(3))
+
+ // Commit 4: an update of v2 on one row of each file group.
+ withWriteLayout(Forced("k string")) {
+ spark.sql(s"update $tableName set " +
+ """v2 = parse_json(concat('{"k":"u', id, '"}')), ts = 1002 where
id in (2, 4)""")
+ }
+ val commit4 = latestCompletedInstant(tablePath)
+ expectLogBlockType match {
+ case None =>
+ // COW rewrites both groups; the old group's new version gains
v2 while the
+ // version it replaces (kept until clean) still lacks it.
+ val firstGroup = baseLayouts(tablePath, "v2").filter(_.fileId ==
firstFileId)
+ assert(firstGroup.map(_.instantTime).toSet == Set(commit1,
commit4),
+ s"[$leg] the update should rewrite the commit-1 file group:
${firstGroup.map(_.path)}")
+ firstGroup.foreach { layout =>
+ if (layout.instantTime == commit1) {
+ assert(!layout.hasColumn, s"[$leg] the replaced version must
not have v2: ${layout.path}")
+ } else {
+ assert(layout.hasColumn && layout.typedFields.contains("k"),
+ s"[$leg] the rewritten version should carry v2 shredded on
k: ${layout.path}")
+ }
+ }
+ val rewritten = baseLayouts(tablePath,
"v2").filter(_.instantTime == commit4)
+ assert(rewritten.map(_.fileId).distinct.size == 2,
+ s"[$leg] the update should rewrite both file groups:
${rewritten.map(_.path)}")
+ assertAllShredded(rewritten, shredded = true, s"$leg v2 in the
rewritten base files")
+ assertAllShredded(baseLayouts(tablePath).filter(_.instantTime ==
commit4), shredded = true,
+ s"$leg v in the rewritten base files")
+ case Some(blockType) =>
+ // MOR: the log block from before the DDL plus one per file
group from this update;
+ // the base files stay as they were.
+ val blockTypes = listLogBlockTypes(tablePath)
+ assert(blockTypes.count(_ == blockType) == 3 &&
blockTypes.forall(_ == blockType),
+ s"[$leg] expected exactly three $blockType blocks, found:
$blockTypes")
+ if (blockType == HoodieLogBlockType.PARQUET_DATA_BLOCK) {
+ val (preDdlLogs, updateLogs) = nativeLogLayouts(tablePath,
"v2")
+ .partition(log => preDdlLogInstant.contains(log.instantTime))
+ assert(preDdlLogs.size == 1 && !preDdlLogs.head.hasColumn,
+ s"[$leg] the log file written before the DDL must not have
v2: ${preDdlLogs.map(_.path)}")
+ assert(updateLogs.size == 2 && updateLogs.forall(_.hasColumn),
+ s"[$leg] both log files of the update should carry v2:
${updateLogs.map(_.path)}")
+ assertAllShredded(updateLogs, shredded = true, s"$leg v2 in
the log files of the update")
+ }
+ }
+
+ def updatedV2(id: Int): String = id match {
+ case 2 | 4 => s"u$id"
+ case _ if id < 3 => null
+ case _ => s"z$id"
+ }
+ // One filter per slot: the update of each file group (ids 2 and 4)
and the new base file.
+ assertReads(tableName, leg, 0 until 6, updatedV2, v2FilterIds =
Seq(2, 3, 4))
+
+ if (expectLogBlockType.isDefined) {
+ // Compaction folds each log block into a base file without v2
(old group) or with it.
+ withWriteLayout(Forced("k string")) {
+ runCompaction(tableName)
+ }
+ assertCompactionCount(tablePath, 1, leg)
+ val compactionInstant = latestCompletedInstant(tablePath)
+ val compacted = baseLayouts(tablePath, "v2").filter(_.instantTime
== compactionInstant)
+ assert(compacted.map(_.fileId).distinct.size == 2 &&
compacted.forall(_.hasColumn),
+ s"[$leg] compaction should write both file groups with v2:
${compacted.map(_.path)}")
+ assertAllShredded(compacted, shredded = true, s"$leg v2 in the
compacted base files")
+ assertAllShredded(baseLayouts(tablePath).filter(_.instantTime ==
compactionInstant),
+ shredded = true, s"$leg v in the compacted base files")
+ assertReads(tableName, leg, 0 until 6, updatedV2, v2FilterIds =
Seq(2, 3, 4))
+ }
+ }
+ }
+ }
+
+ // COW, SPARK records: the update goes through the merge handle, which has
to add a null v2 for
+ // the untouched rows while keeping v shredded.
+ runLeg("cow, second variant by DDL", "cow", Seq(HoodieRecordType.SPARK),
+ Seq.empty, expectLogBlockType = None)
+ // MOR, SPARK records on the current table version: native parquet log
files.
+ runLeg("mor, parquet log blocks", "mor", Seq(HoodieRecordType.SPARK),
+ Seq("hoodie.compact.inline = 'false'"), expectLogBlockType =
Some(HoodieLogBlockType.PARQUET_DATA_BLOCK))
+ // MOR, avro data blocks: table version 9 is the only way to get them; SQL
DML and the DDL both
+ // pick the version and block format up from the tblproperties.
+ runLeg("mor, avro data blocks", "mor", Seq(HoodieRecordType.AVRO),
+ Seq("hoodie.compact.inline = 'false'",
+ "hoodie.write.table.version = '9'",
+ "hoodie.logfile.data.block.format = 'avro'"),
+ expectLogBlockType = Some(HoodieLogBlockType.AVRO_DATA_BLOCK))
+ }
+
//
-----------------------------------------------------------------------------------------------
// H. Robustness
//
-----------------------------------------------------------------------------------------------