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 85033e339f66 test(spark): add write and commit path coverage (#19163)
85033e339f66 is described below
commit 85033e339f667c0d0bd8ed16de465bf27c517e3a
Author: Y Ethan Guo <[email protected]>
AuthorDate: Thu Aug 27 06:56:02 2026 -0700
test(spark): add write and commit path coverage (#19163)
The row-writer bulk_insert overwrite path in hudi-spark-common had no
SQL-level coverage of rejectIfOverlappingPendingClustering (#18829 only
drives the RDD path) or of the static, dynamic and unpartitioned arms of
DatasetBulkInsertOverwriteCommitActionExecutor.resolveTargetPartitions.
Tests, both in TestInsertTable2 and pinned to the row writer via
hoodie.datasource.write.row.writer.enable=true:
- "Test bulk insert with insert overwrite against pending clustering":
two partitions, clustering scheduled on one via run_clustering,
parameterized over static and dynamic overwrite mode. Overwriting the
non-clustered partition succeeds (INSERT_OVERWRITE, exactly one file
id replaced, plan still pending); overwriting the clustered one is
rejected by SparkRejectUpdateStrategy before any data file is written.
The static arm also rejects "partition (dt)" without a value, which
resolves to every partition.
- "Test bulk insert with insert overwrite on unpartitioned table against
pending clustering": a forced insert_overwrite (rejected), the deduced
INSERT_OVERWRITE_TABLE through
SparkInsertOverwriteTableCommitActionExecutor (rejected), and
SparkAllowUpdateStrategy (goes through, plan left pending).
Fix: HoodieSparkSqlWriter.bulkInsertAsRow now closes the write client
when executor.execute throws, mirroring the non-row-writer path. Every
rejection above used to leak the client and its embedded timeline
server.
Test base: HoodieSparkSqlTestBase clears the INMEMORY index's JVM-static
record-location map after every test, before the catalog cleanup so it
holds even if a drop throws, and gains getLastReplaceCommitMetadata next
to getLastCommitMetadata.
Not covered: the row-writer INSERT_OVERWRITE_TABLE variant deletes and
re-initializes the table before the check runs (#19771). Found on the
way, not fixed here: ALTER TABLE RENAME COLUMN / DROP COLUMN fail under
default configs on a schema-on-read table since #13595 (#19766), and the
pending-clustering check is bypassed when meta fields are not populated
(#19770).
---------
Co-authored-by: voon <[email protected]>
---
.../org/apache/hudi/HoodieSparkSqlWriter.scala | 9 +-
.../sql/hudi/common/HoodieSparkSqlTestBase.scala | 14 +-
.../sql/hudi/dml/insert/TestInsertTable2.scala | 162 ++++++++++++++++++++-
3 files changed, 181 insertions(+), 4 deletions(-)
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala
index 226d8dd1e568..042af12a2954 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala
@@ -867,7 +867,14 @@ class HoodieSparkSqlWriterInternal {
throw new HoodieException(s"$mode with bulk_insert in row writer path
is not supported yet");
}
- val writeResult = executor.execute(df, tableConfig.isTablePartitioned)
+ val writeResult = try {
+ executor.execute(df, tableConfig.isTablePartitioned)
+ } catch {
+ case e: HoodieException =>
+ // close the write client in all cases
+ closeWriteClient(writeClient, tableConfig, parameters,
jsc.hadoopConfiguration())
+ throw e
+ }
try {
val (writeSuccessful, compactionInstant, clusteringInstant) =
commitAndPerformPostOperations(
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/HoodieSparkSqlTestBase.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/HoodieSparkSqlTestBase.scala
index 137620a4cdbe..64b9b1275e46 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/HoodieSparkSqlTestBase.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/HoodieSparkSqlTestBase.scala
@@ -22,7 +22,7 @@ import org.apache.hudi.HoodieFileIndex.DataSkippingFailureMode
import org.apache.hudi.common.avro.AvroSchemaCache
import org.apache.hudi.common.config.{HoodieCommonConfig,
HoodieMetadataConfig, HoodieStorageConfig}
import org.apache.hudi.common.engine.HoodieLocalEngineContext
-import org.apache.hudi.common.model.{FileSlice, HoodieAvroRecordMerger,
HoodieLogFile, HoodieRecord}
+import org.apache.hudi.common.model.{FileSlice, HoodieAvroRecordMerger,
HoodieLogFile, HoodieRecord, HoodieReplaceCommitMetadata}
import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType
import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient,
TableSchemaResolver}
import org.apache.hudi.common.table.log.HoodieLogFormat
@@ -113,6 +113,12 @@ class HoodieSparkSqlTestBase extends FunSuite with
BeforeAndAfterAll {
try {
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 =>
@@ -403,6 +409,12 @@ object HoodieSparkSqlTestBase {
metaClient.getActiveTimeline.getLastCommitMetadataWithValidData.get.getRight
}
+ def getLastReplaceCommitMetadata(spark: SparkSession, tablePath: String):
HoodieReplaceCommitMetadata = {
+ val metaClient = createMetaClient(spark, tablePath)
+ val lastInstant =
metaClient.getActiveTimeline.getLastCommitMetadataWithValidData.get.getLeft
+ metaClient.getActiveTimeline.readReplaceCommitMetadata(lastInstant)
+ }
+
def getLastCleanMetadata(spark: SparkSession, tablePath: String) = {
val metaClient = createMetaClient(spark, tablePath)
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/insert/TestInsertTable2.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/insert/TestInsertTable2.scala
index cd2ea668b849..126b680a67a9 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/insert/TestInsertTable2.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/insert/TestInsertTable2.scala
@@ -19,24 +19,28 @@
package org.apache.spark.sql.hudi.dml.insert
-import org.apache.hudi.DataSourceWriteOptions.{COW_TABLE_TYPE_OPT_VAL,
MOR_TABLE_TYPE_OPT_VAL, PARTITIONPATH_FIELD, RECORDKEY_FIELD,
SPARK_SQL_INSERT_INTO_OPERATION, TABLE_TYPE}
+import org.apache.hudi.DataSourceWriteOptions.{COW_TABLE_TYPE_OPT_VAL,
ENABLE_ROW_WRITER, MOR_TABLE_TYPE_OPT_VAL, PARTITIONPATH_FIELD,
RECORDKEY_FIELD, SPARK_SQL_INSERT_INTO_OPERATION, TABLE_TYPE}
import org.apache.hudi.DataSourceWriteOptions
import org.apache.hudi.HoodieSparkUtils
import org.apache.hudi.client.WriteClientTestUtils
+import
org.apache.hudi.client.clustering.update.strategy.SparkAllowUpdateStrategy
import org.apache.hudi.common.model.WriteOperationType
import org.apache.hudi.common.table.HoodieTableConfig
import org.apache.hudi.common.table.TableSchemaResolver
import org.apache.hudi.common.table.timeline.{HoodieInstant, HoodieTimeline}
import org.apache.hudi.common.testutils.HoodieTestUtils
+import org.apache.hudi.config.HoodieClusteringConfig
import org.apache.hudi.config.HoodieWriteConfig
import org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient
import org.apache.spark.sql.SaveMode
import org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase
-import
org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase.getLastCommitMetadata
+import
org.apache.spark.sql.hudi.common.HoodieSparkSqlTestBase.{getLastCommitMetadata,
getLastReplaceCommitMetadata}
import java.io.File
+import scala.collection.JavaConverters._
+
class TestInsertTable2 extends HoodieSparkSqlTestBase {
test("Test Different Type of Partition Column") {
@@ -693,6 +697,160 @@ class TestInsertTable2 extends HoodieSparkSqlTestBase {
}
}
+ test("Test bulk insert with insert overwrite against pending clustering") {
+ // The only coverage of rejectIfOverlappingPendingClustering on the
row-writer path; the RDD
+ // path is covered by TestInsertOverwriteWithClustering. Clustering is
pending on one of two
+ // partitions: overwriting the other must succeed and leave the plan
pending, overwriting the
+ // clustered one must be rejected before anything is written. Static mode
resolves the target
+ // partition from the PARTITION clause and dynamic mode from the rows, so
both arms of
+ // DatasetBulkInsertOverwriteCommitActionExecutor.resolveTargetPartitions
are driven, with meta
+ // fields populated (the unpopulated case bypasses the check today, see
#19770).
+ Seq("static", "dynamic").foreach { overwriteMode =>
+ withSQLConf(SPARK_SQL_INSERT_INTO_OPERATION.key ->
WriteOperationType.BULK_INSERT.value(),
+ ENABLE_ROW_WRITER.key -> "true",
+ "hoodie.datasource.overwrite.mode" -> overwriteMode) {
+ withTempDir { tmp =>
+ withTable(generateTableName) { tableName =>
+ val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | name string,
+ | price double,
+ | dt string
+ |) using hudi
+ | tblproperties (
+ | type = 'cow',
+ | primaryKey = 'id'
+ | )
+ | partitioned by (dt)
+ | location '$tablePath'
+ """.stripMargin)
+ // Two commits into 2021-07-18 give the size-based planner two
file groups to cluster.
+ spark.sql(s"insert into $tableName values(1, 'a1', 10,
'2021-07-18')")
+ spark.sql(s"insert into $tableName values(2, 'a2', 20,
'2021-07-18')")
+ spark.sql(s"insert into $tableName values(3, 'a3', 30,
'2021-07-19')")
+ spark.sql(s"call run_clustering(table => '$tableName', op =>
'schedule', selected_partitions => 'dt=2021-07-18')")
+ val metaClient = createMetaClient(spark, tablePath)
+
assertResult(1)(metaClient.getActiveTimeline.filterPendingClusteringTimeline().countInstants())
+
+ def overwriteSql(dt: String, id: Int, name: String, price: Int):
String = overwriteMode match {
+ case "static" => s"insert overwrite table $tableName partition
(dt = '$dt') values($id, '$name', $price)"
+ case "dynamic" => s"insert overwrite table $tableName partition
(dt) values($id, '$name', $price, '$dt')"
+ }
+
+ // Non-overlapping partition: the overwrite goes through and the
plan stays pending.
+ spark.sql(overwriteSql("2021-07-19", 4, "b1", 40))
+ checkAnswer(s"select id, name, price, dt from $tableName order by
id")(
+ Seq(1, "a1", 10.0, "2021-07-18"),
+ Seq(2, "a2", 20.0, "2021-07-18"),
+ Seq(4, "b1", 40.0, "2021-07-19")
+ )
+ assertResult(WriteOperationType.INSERT_OVERWRITE) {
+ getLastCommitMetadata(spark, tablePath).getOperationType
+ }
+ val replaced = getLastReplaceCommitMetadata(spark,
tablePath).getPartitionToReplaceFileIds
+ assertResult(Set("dt=2021-07-19"))(replaced.keySet().asScala.toSet)
+ assertResult(1)(replaced.get("dt=2021-07-19").size())
+
assertResult(1)(metaClient.reloadActiveTimeline().filterPendingClusteringTimeline().countInstants())
+
+ // Overlapping partition: rejected by the default
SparkRejectUpdateStrategy before any
+ // write materializes, so no data files are written (the requested
instant is left behind).
+ checkExceptionContain(overwriteSql("2021-07-18", 1, "a1_new", 11))(
+ "Not allowed to update the clustering file group")
+ checkAnswer(s"select id, name, price, dt from $tableName order by
id")(
+ Seq(1, "a1", 10.0, "2021-07-18"),
+ Seq(2, "a2", 20.0, "2021-07-18"),
+ Seq(4, "b1", 40.0, "2021-07-19")
+ )
+
+ if (overwriteMode == "static") {
+ // Without a partition value, static mode expands `partition
(dt)` to every existing
+ // partition, a two-element STATIC_OVERWRITE_PARTITION_PATHS
(the HUDI-7183 shape),
+ // which overlaps the plan and is rejected. Dynamic mode
resolves only the row's
+ // partition, which is the successful statement above.
+ checkExceptionContain(s"insert overwrite table $tableName
partition (dt) values(5, 'c1', 50, '2021-07-19')")(
+ "Not allowed to update the clustering file group")
+ checkAnswer(s"select id, name, price, dt from $tableName order
by id")(
+ Seq(1, "a1", 10.0, "2021-07-18"),
+ Seq(2, "a2", 20.0, "2021-07-18"),
+ Seq(4, "b1", 40.0, "2021-07-19")
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ test("Test bulk insert with insert overwrite on unpartitioned table against
pending clustering") {
+ // Forcing INSERT_OVERWRITE drives the unpartitioned arm of
+ // DatasetBulkInsertOverwriteCommitActionExecutor.resolveTargetPartitions.
The deduced
+ // INSERT_OVERWRITE_TABLE is covered off the row writer only: on that path
SaveMode.Overwrite
+ // recreates the table before the executor runs, taking the pending plan
with it (#15984).
+ withSQLConf(SPARK_SQL_INSERT_INTO_OPERATION.key ->
WriteOperationType.BULK_INSERT.value(),
+ ENABLE_ROW_WRITER.key -> "true") {
+ withTempDir { tmp =>
+ withTable(generateTableName) { tableName =>
+ val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | name string,
+ | price double
+ |) using hudi
+ | tblproperties (
+ | type = 'cow',
+ | primaryKey = 'id'
+ | )
+ | location '$tablePath'
+ """.stripMargin)
+ spark.sql(s"insert into $tableName values(1, 'a1', 10)")
+ spark.sql(s"insert into $tableName values(2, 'a2', 20)")
+ spark.sql(s"call run_clustering(table => '$tableName', op =>
'schedule')")
+ assertResult(1)(createMetaClient(spark,
tablePath).getActiveTimeline.filterPendingClusteringTimeline().countInstants())
+
+ // Scoped to the overwrite statement only, so the seeding inserts
above stay inserts rather
+ // than overwrites and each seeds its own file group.
+ withSQLConf(DataSourceWriteOptions.OPERATION.key ->
WriteOperationType.INSERT_OVERWRITE.value()) {
+ checkExceptionContain(s"insert overwrite table $tableName
values(3, 'b1', 30)")(
+ "Not allowed to update the clustering file group")
+ }
+ checkAnswer(s"select id, name, price from $tableName order by id")(
+ Seq(1, "a1", 10.0),
+ Seq(2, "a2", 20.0)
+ )
+
+ // The deduced INSERT_OVERWRITE_TABLE is covered off the row writer,
since the row-writer
+ // variant recreates the table before the check (#15984): the
RDD-side
+ // SparkInsertOverwriteTableCommitActionExecutor override must
reject as well.
+ withSQLConf(SPARK_SQL_INSERT_INTO_OPERATION.key ->
WriteOperationType.INSERT.value()) {
+ checkExceptionContain(s"insert overwrite table $tableName
values(3, 'b1', 30)")(
+ "Not allowed to update the clustering file group")
+ }
+ checkAnswer(s"select id, name, price from $tableName order by id")(
+ Seq(1, "a1", 10.0),
+ Seq(2, "a2", 20.0)
+ )
+
+ // SparkAllowUpdateStrategy is the other branch of
rejectIfOverlappingPendingClustering:
+ // the overlap is deferred to conflict resolution, the overwrite
goes through and the plan
+ // is left pending.
+ withSQLConf(DataSourceWriteOptions.OPERATION.key ->
WriteOperationType.INSERT_OVERWRITE.value(),
+ HoodieClusteringConfig.UPDATES_STRATEGY.key ->
classOf[SparkAllowUpdateStrategy[_]].getName) {
+ spark.sql(s"insert overwrite table $tableName values(3, 'b1', 30)")
+ }
+ checkAnswer(s"select id, name, price from $tableName order by id")(
+ Seq(3, "b1", 30.0)
+ )
+ assertResult(1)(createMetaClient(spark,
tablePath).getActiveTimeline.filterPendingClusteringTimeline().countInstants())
+ }
+ }
+ }
+ }
+
test("Test combine before insert") {
Seq("cow", "mor").foreach { tableType =>
withSQLConf("hoodie.sql.bulk.insert.enable" -> "false",
"hoodie.merge.allow.duplicate.on.inserts" -> "false",