This is an automated email from the ASF dual-hosted git repository.
danny0405 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 2460f8bed20a feat(spark): add DeltaStreamer checkpoint procedures
(#19631)
2460f8bed20a is described below
commit 2460f8bed20a8d8a5c1d922645f4345a0b425b22
Author: Danny Chan <[email protected]>
AuthorDate: Sat Aug 22 12:23:41 2026 +0800
feat(spark): add DeltaStreamer checkpoint procedures (#19631)
* feat(spark): add DeltaStreamer checkpoint procedures
---
.../DeltaStreamerCheckpointProcedure.scala | 205 +++++++++++++++++++++
.../hudi/command/procedures/HoodieProcedures.scala | 2 +
.../TestDeltaStreamerCheckpointProcedure.scala | 199 ++++++++++++++++++++
3 files changed, 406 insertions(+)
diff --git
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/DeltaStreamerCheckpointProcedure.scala
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/DeltaStreamerCheckpointProcedure.scala
new file mode 100644
index 000000000000..fc386f7fcdb8
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/DeltaStreamerCheckpointProcedure.scala
@@ -0,0 +1,205 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.hudi.command.procedures
+
+import org.apache.hudi.HoodieCLIUtils
+import org.apache.hudi.client.SparkRDDWriteClient
+import org.apache.hudi.common.config.HoodieMetadataConfig
+import org.apache.hudi.common.model.{HoodieFailedWritesCleaningPolicy,
HoodieRecord, HoodieTableType}
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.common.table.checkpoint.{Checkpoint, CheckpointUtils,
StreamerCheckpointV1, StreamerCheckpointV2}
+import org.apache.hudi.common.table.timeline.{HoodieTimeline, TimelineUtils}
+import org.apache.hudi.common.util.{Option => HOption}
+import org.apache.hudi.config.{HoodieCleanConfig, HoodieWriteConfig}
+import org.apache.hudi.exception.HoodieException
+
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.types.{DataTypes, Metadata, StructField,
StructType}
+
+import java.util.function.Supplier
+
+import scala.util.control.NonFatal
+
+class GetDeltaStreamerCheckpointProcedure extends BaseProcedure with
ProcedureBuilder {
+ import DeltaStreamerCheckpointProcedureUtils._
+
+ private val PARAMETERS = Array[ProcedureParameter](
+ ProcedureParameter.optional(0, "table", DataTypes.StringType),
+ ProcedureParameter.optional(1, "path", DataTypes.StringType)
+ )
+
+ override def parameters: Array[ProcedureParameter] = PARAMETERS
+
+ override def outputType: StructType = OUTPUT_TYPE
+
+ override def call(args: ProcedureArgs): Seq[Row] = {
+ val tableName = getArgValueOrDefault(args, PARAMETERS(0))
+ val tablePath = getArgValueOrDefault(args, PARAMETERS(1))
+ val metaClient = createMetaClient(jsc, getBasePath(tableName, tablePath))
+
+ val checkpoint = getLatestCheckpoint(metaClient)
+ if (checkpoint.isPresent) {
+ Seq(Row(checkpoint.get.getCheckpointKey))
+ } else {
+ Seq.empty
+ }
+ }
+
+ override def build: Procedure = new GetDeltaStreamerCheckpointProcedure
+}
+
+/**
+ * Publishes a DeltaStreamer checkpoint through an empty commit. Callers
should pause active
+ * ingestion unless the table is configured for multi-writer concurrency
control and locking.
+ * A later ingestion commit can legitimately advance the checkpoint again.
+ */
+class SetDeltaStreamerCheckpointProcedure extends BaseProcedure with
ProcedureBuilder {
+ import DeltaStreamerCheckpointProcedureUtils._
+
+ private val PARAMETERS = Array[ProcedureParameter](
+ ProcedureParameter.optional(0, "table", DataTypes.StringType),
+ ProcedureParameter.required(1, "checkpoint", DataTypes.StringType),
+ ProcedureParameter.optional(2, "path", DataTypes.StringType)
+ )
+
+ override def parameters: Array[ProcedureParameter] = PARAMETERS
+
+ override def outputType: StructType = OUTPUT_TYPE
+
+ override def call(args: ProcedureArgs): Seq[Row] = {
+ super.checkArgs(PARAMETERS, args)
+
+ val tableName = getArgValueOrDefault(args, PARAMETERS(0))
+ val checkpointValue = getArgValueOrDefault(args,
PARAMETERS(1)).get.asInstanceOf[String]
+ if (checkpointValue.trim.isEmpty) {
+ throw new IllegalArgumentException("DeltaStreamer checkpoint must not be
empty")
+ }
+ val tablePath = getArgValueOrDefault(args, PARAMETERS(2))
+ val basePath = getBasePath(tableName, tablePath)
+ val metaClient = createMetaClient(jsc, basePath)
+
+ val checkpoint = getLatestCheckpoint(metaClient)
+ .orElse(new StreamerCheckpointV1(checkpointValue))
+ checkpoint.setCheckpointKey(checkpointValue)
+ val checkpointMetadata = checkpoint.getCheckpointCommitMetadata(
+ checkpoint.getCheckpointResetKey, checkpoint.getCheckpointIgnoreKey)
+
+ val writeOptions = Map(
+ // This procedure only publishes checkpoint metadata. It must not run or
schedule table
+ // services as a side effect or clean up pending writes belonging to
another writer.
+ HoodieWriteConfig.TABLE_SERVICES_ENABLED.key -> "false",
+ HoodieCleanConfig.FAILED_WRITES_CLEANER_POLICY.key ->
HoodieFailedWritesCleaningPolicy.NEVER.name,
+ HoodieWriteConfig.ALLOW_EMPTY_COMMIT.key -> "true",
+ // Never upgrade or downgrade the table as a side effect of setting its
checkpoint.
+ HoodieWriteConfig.WRITE_TABLE_VERSION.key ->
metaClient.getTableConfig.getTableVersion.versionCode().toString,
+ HoodieWriteConfig.AUTO_UPGRADE_VERSION.key -> "false",
+ // A minimally configured writer must never remove metadata partitions
that are already
+ // present on disk. Available partitions are still updated based on
hoodie.properties.
+ HoodieMetadataConfig.AUTO_DELETE_PARTITIONS.key -> "false"
+ )
+
+ var client: SparkRDDWriteClient[AnyRef] = null
+ var instantTime: String = null
+ try {
+ client = HoodieCLIUtils.createHoodieWriteClient(
+ sparkSession,
+ basePath,
+ writeOptions,
+ tableName.map(_.asInstanceOf[String]))
+ .asInstanceOf[SparkRDDWriteClient[AnyRef]]
+
+ instantTime = client.startCommit(metaClient.getCommitActionType)
+ val writeStatuses = client.upsert(jsc.emptyRDD[HoodieRecord[AnyRef]],
instantTime)
+ val committed = client.commit(instantTime, writeStatuses,
HOption.of(checkpointMetadata))
+ if (!committed) {
+ throw new HoodieException(s"Failed to set DeltaStreamer checkpoint for
table at $basePath")
+ }
+ Seq(Row(checkpointValue))
+ } catch {
+ case NonFatal(failure) =>
+ // Roll back only if our instant is still pending. A commit can throw
after completing;
+ // rolling back that completed instant would discard a successfully
published checkpoint.
+ if (client != null && instantTime != null) {
+ try {
+ val stillPending = metaClient.reloadActiveTimeline()
+ .filterInflightsAndRequested()
+ .containsInstant(instantTime)
+ if (stillPending && !client.rollback(instantTime)) {
+ failure.addSuppressed(new HoodieException(
+ s"Failed to rollback DeltaStreamer checkpoint instant
$instantTime"))
+ }
+ } catch {
+ case NonFatal(rollbackFailure) =>
failure.addSuppressed(rollbackFailure)
+ }
+ }
+ throw failure
+ } finally {
+ if (client != null) {
+ client.close()
+ }
+ }
+ }
+
+ override def build: Procedure = new SetDeltaStreamerCheckpointProcedure
+}
+
+private object DeltaStreamerCheckpointProcedureUtils {
+ private val CHECKPOINT_KEYS = Array(
+ StreamerCheckpointV1.STREAMER_CHECKPOINT_KEY_V1,
+ StreamerCheckpointV1.STREAMER_CHECKPOINT_RESET_KEY_V1,
+ StreamerCheckpointV2.STREAMER_CHECKPOINT_KEY_V2,
+ StreamerCheckpointV2.STREAMER_CHECKPOINT_RESET_KEY_V2
+ )
+
+ val OUTPUT_TYPE: StructType = new StructType(Array[StructField](
+ StructField("checkpoint", DataTypes.StringType, nullable = true,
Metadata.empty)
+ ))
+
+ def getLatestCheckpoint(metaClient: HoodieTableMetaClient):
HOption[Checkpoint] = {
+ val commitsTimeline = getIngestionTimeline(metaClient)
+ TimelineUtils.getLatestInstantAndCommitMetadataWithValidCheckpointInfo(
+ commitsTimeline, CHECKPOINT_KEYS: _*)
+ .map(pair => CheckpointUtils.getCheckpoint(pair.getRight))
+ }
+
+ private def getIngestionTimeline(metaClient: HoodieTableMetaClient):
HoodieTimeline = {
+ val commitsTimeline =
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants
+ val deltaCommitTimeline = commitsTimeline.filter(
+ instant => instant.getAction == HoodieTimeline.DELTA_COMMIT_ACTION)
+
+ // Match Streamer's resume behavior: once a MOR table has delta commits,
checkpoints from
+ // older COW commits are no longer considered.
+ if (metaClient.getTableType == HoodieTableType.MERGE_ON_READ &&
!deltaCommitTimeline.empty()) {
+ deltaCommitTimeline
+ } else {
+ commitsTimeline
+ }
+ }
+}
+
+object GetDeltaStreamerCheckpointProcedure {
+ val NAME = "get_deltastreamer_checkpoint"
+
+ def builder: Supplier[ProcedureBuilder] = () => new
GetDeltaStreamerCheckpointProcedure
+}
+
+object SetDeltaStreamerCheckpointProcedure {
+ val NAME = "set_deltastreamer_checkpoint"
+
+ def builder: Supplier[ProcedureBuilder] = () => new
SetDeltaStreamerCheckpointProcedure
+}
diff --git
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala
index f602b6506264..e5fd5285bf3c 100644
---
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala
+++
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala
@@ -111,6 +111,8 @@ object HoodieProcedures {
,(ShowAuditLockStatusProcedure.NAME,
ShowAuditLockStatusProcedure.builder)
,(ValidateAuditLockProcedure.NAME, ValidateAuditLockProcedure.builder)
,(CleanupAuditLockProcedure.NAME, CleanupAuditLockProcedure.builder)
+ ,(GetDeltaStreamerCheckpointProcedure.NAME,
GetDeltaStreamerCheckpointProcedure.builder)
+ ,(SetDeltaStreamerCheckpointProcedure.NAME,
SetDeltaStreamerCheckpointProcedure.builder)
)
}
}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestDeltaStreamerCheckpointProcedure.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestDeltaStreamerCheckpointProcedure.scala
new file mode 100644
index 000000000000..2016bd311f02
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestDeltaStreamerCheckpointProcedure.scala
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.hudi.procedure
+
+import org.apache.hudi.DataSourceWriteOptions.{ORDERING_FIELDS,
RECORDKEY_FIELD, TABLE_TYPE}
+import org.apache.hudi.common.table.{HoodieTableMetaClient, HoodieTableVersion}
+import org.apache.hudi.common.table.timeline.{HoodieInstant, HoodieTimeline}
+import org.apache.hudi.common.util.{Option => HOption}
+import org.apache.hudi.config.{HoodieCompactionConfig, HoodieWriteConfig}
+import org.apache.hudi.hadoop.fs.HadoopFSUtils
+
+import org.apache.spark.sql.SaveMode
+
+class TestDeltaStreamerCheckpointProcedure extends
HoodieSparkProcedureTestBase {
+
+ Seq("cow", "mor").foreach { tableType =>
+ test(s"get and set deltastreamer checkpoint for $tableType table") {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | name string,
+ | ts long
+ |) using hudi
+ | location '$tablePath'
+ | tblproperties (
+ | type = '$tableType',
+ | primaryKey = 'id',
+ | orderingFields = 'ts'
+ | )
+ |""".stripMargin)
+ spark.sql(s"insert into $tableName values (1, 'a1', 1000)")
+
+ assertResult(0) {
+ spark.sql(s"call get_deltastreamer_checkpoint(table =>
'$tableName')").count()
+ }
+
+ checkAnswer(
+ s"call set_deltastreamer_checkpoint(table => '$tableName',
checkpoint => 'checkpoint-1')")(
+ Seq("checkpoint-1"))
+ checkAnswer(s"call get_deltastreamer_checkpoint(table =>
'$tableName')")(
+ Seq("checkpoint-1"))
+
+ checkAnswer(s"select id, name, ts from $tableName")(Seq(1, "a1",
1000L))
+
+ // Both procedures can address a table by path when it is not
registered in the catalog.
+ checkAnswer(
+ s"call set_deltastreamer_checkpoint(path => '$tablePath', checkpoint
=> 'checkpoint-2')")(
+ Seq("checkpoint-2"))
+ checkAnswer(s"call get_deltastreamer_checkpoint(path =>
'$tablePath')")(
+ Seq("checkpoint-2"))
+
+ checkExceptionContain(
+ s"call set_deltastreamer_checkpoint(table => '$tableName',
checkpoint => ' ')")(
+ "DeltaStreamer checkpoint must not be empty")
+ checkAnswer(s"call get_deltastreamer_checkpoint(table =>
'$tableName')")(
+ Seq("checkpoint-2"))
+ }
+ }
+ }
+
+ test("setting checkpoint does not remove metadata table partitions") {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | name string,
+ | ts long
+ |) using hudi
+ | location '$tablePath'
+ | tblproperties (
+ | primaryKey = 'id',
+ | orderingFields = 'ts',
+ | hoodie.metadata.enable = 'true',
+ | hoodie.metadata.index.column.stats.enable = 'true'
+ | )
+ |""".stripMargin)
+ spark.sql(s"insert into $tableName values (1, 'a1', 1000)")
+
+ val metadataPartitionsBefore =
loadMetaClient(tablePath).getTableConfig.getMetadataPartitions
+ assert(metadataPartitionsBefore.contains("files"))
+ assert(metadataPartitionsBefore.contains("column_stats"))
+
+ checkAnswer(
+ s"call set_deltastreamer_checkpoint(table => '$tableName', checkpoint
=> 'checkpoint-1')")(
+ Seq("checkpoint-1"))
+
+ assertResult(metadataPartitionsBefore) {
+ loadMetaClient(tablePath).getTableConfig.getMetadataPartitions
+ }
+ }
+ }
+
+ test("setting checkpoint does not schedule table services") {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | name string,
+ | ts long
+ |) using hudi
+ | location '$tablePath'
+ | tblproperties (
+ | type = 'mor',
+ | primaryKey = 'id',
+ | orderingFields = 'ts'
+ | )
+ |""".stripMargin)
+ spark.sql(s"insert into $tableName values (1, 'a1', 1000)")
+
+ withSQLConf(
+ HoodieCompactionConfig.SCHEDULE_INLINE_COMPACT.key -> "true",
+ HoodieCompactionConfig.INLINE_COMPACT_NUM_DELTA_COMMITS.key -> "1") {
+ checkAnswer(
+ s"call set_deltastreamer_checkpoint(table => '$tableName',
checkpoint => 'checkpoint-1')")(
+ Seq("checkpoint-1"))
+ }
+
+
assert(loadMetaClient(tablePath).getActiveTimeline.filterPendingCompactionTimeline.empty())
+ }
+ }
+
+ test("setting checkpoint preserves table version and pending instants") {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ val hoodieTableName = tableName.split('.').last
+ val tablePath = s"${tmp.getCanonicalPath}/$hoodieTableName"
+ import spark.implicits._
+ Seq((1, "a1", 1000L)).toDF("id", "name", "ts")
+ .write.format("hudi")
+ .option(HoodieWriteConfig.TBL_NAME.key, hoodieTableName)
+ .option(TABLE_TYPE.key, "COPY_ON_WRITE")
+ .option(RECORDKEY_FIELD.key, "id")
+ .option(ORDERING_FIELDS.key, "ts")
+ .option(HoodieWriteConfig.WRITE_TABLE_VERSION.key,
HoodieTableVersion.EIGHT.versionCode().toString)
+ .option(HoodieWriteConfig.AUTO_UPGRADE_VERSION.key, "false")
+ .option("hoodie.metadata.enable", "false")
+ .mode(SaveMode.Overwrite)
+ .save(tablePath)
+ spark.sql(s"create table $tableName using hudi location '$tablePath'")
+
+ val metaClient = loadMetaClient(tablePath)
+
assertResult(HoodieTableVersion.EIGHT)(metaClient.getTableConfig.getTableVersion)
+ val pendingInstantTime = metaClient.createNewInstantTime(false)
+ val requested =
metaClient.getTimelineLayout.getInstantGenerator.createNewInstant(
+ HoodieInstant.State.REQUESTED, HoodieTimeline.COMMIT_ACTION,
pendingInstantTime)
+ metaClient.getActiveTimeline.createNewInstant(requested)
+ metaClient.getActiveTimeline.transitionRequestedToInflight(
+ requested, HOption.empty[Array[Byte]]())
+
+ checkAnswer(
+ s"call set_deltastreamer_checkpoint(table => '$tableName', checkpoint
=> 'checkpoint-1')")(
+ Seq("checkpoint-1"))
+
+ val updatedMetaClient = loadMetaClient(tablePath)
+
assertResult(HoodieTableVersion.EIGHT)(updatedMetaClient.getTableConfig.getTableVersion)
+ val preservedPendingInstant = updatedMetaClient.getActiveTimeline
+ .filterInflightsAndRequested()
+ .getInstantsAsStream
+ .filter(instant => instant.requestedTime == pendingInstantTime)
+ .findFirst()
+ assert(preservedPendingInstant.isPresent)
+ assert(preservedPendingInstant.get.isInflight)
+ checkAnswer(s"call get_deltastreamer_checkpoint(table => '$tableName')")(
+ Seq("checkpoint-1"))
+ }
+ }
+
+ private def loadMetaClient(tablePath: String): HoodieTableMetaClient = {
+ HoodieTableMetaClient.builder()
+
.setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration))
+ .setBasePath(tablePath)
+ .build()
+ }
+}