You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hudi.apache.org by da...@apache.org on 2026/05/01 03:38:00 UTC

(hudi) branch master updated: feat: Introduce a Spark procedure to trigger LSM timeline compaction (#18659)

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 f8d70cb58d7c feat: Introduce a Spark procedure to trigger LSM timeline compaction (#18659)
f8d70cb58d7c is described below

commit f8d70cb58d7c9ea6a96ece59ef4ce097c46c3921
Author: chaoyang <ch...@apache.org>
AuthorDate: Fri May 1 11:37:54 2026 +0800

    feat: Introduce a Spark procedure to trigger LSM timeline compaction (#18659)
    
    * feat: Introduce a Spark procedure to trigger LSM timeline compaction
    
    * fixup: address review on RunTimelineCompactionProcedure
    
    - Acquire the txn state-change lock around compactAndClean so the procedure cannot race with a concurrent archival/compaction over the LSM timeline manifest
    - Use TimeUnit.NANOSECONDS.toMillis for self-documenting unit conversion
---
 .../hudi/command/procedures/HoodieProcedures.scala |   1 +
 .../RunTimelineCompactionProcedure.scala           |  97 ++++++++++++++++++++
 .../TestRunTimelineCompactionProcedure.scala       | 100 +++++++++++++++++++++
 3 files changed, 198 insertions(+)

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 4319ce77c1c2..508e37d47663 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
@@ -93,6 +93,7 @@ object HoodieProcedures {
       ,(ShowTablePropertiesProcedure.NAME, ShowTablePropertiesProcedure.builder)
       ,(HelpProcedure.NAME, HelpProcedure.builder)
       ,(ArchiveCommitsProcedure.NAME, ArchiveCommitsProcedure.builder)
+      ,(RunTimelineCompactionProcedure.NAME, RunTimelineCompactionProcedure.builder)
       ,(RunTTLProcedure.NAME, RunTTLProcedure.builder)
       ,(DropPartitionProcedure.NAME, DropPartitionProcedure.builder)
       ,(TruncateTableProcedure.NAME, TruncateTableProcedure.builder)
diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunTimelineCompactionProcedure.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunTimelineCompactionProcedure.scala
new file mode 100644
index 000000000000..14b1bcb2d065
--- /dev/null
+++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunTimelineCompactionProcedure.scala
@@ -0,0 +1,97 @@
+/*
+ * 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.client.timeline.versioning.v2.LSMTimelineWriter
+import org.apache.hudi.common.table.timeline.HoodieInstantTimeGenerator
+import org.apache.hudi.common.util.{Option => HOption}
+import org.apache.hudi.table.HoodieSparkTable
+
+import org.apache.spark.internal.Logging
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.types.{DataTypes, Metadata, StructField, StructType}
+
+import java.util.concurrent.TimeUnit
+import java.util.function.Supplier
+
+class RunTimelineCompactionProcedure extends BaseProcedure with ProcedureBuilder with Logging {
+
+  private val PARAMETERS = Array[ProcedureParameter](
+    ProcedureParameter.optional(0, "table", DataTypes.StringType),
+    ProcedureParameter.optional(1, "path", DataTypes.StringType)
+  )
+
+  private val OUTPUT_TYPE = new StructType(Array[StructField](
+    StructField("start_compaction_time", DataTypes.StringType, nullable = true, Metadata.empty),
+    StructField("time_taken_in_millis", DataTypes.LongType, nullable = true, Metadata.empty)
+  ))
+
+  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 tablePath = getArgValueOrDefault(args, PARAMETERS(1))
+    val basePath = getBasePath(tableName, tablePath)
+
+    var client: SparkRDDWriteClient[_] = null
+    try {
+      client = HoodieCLIUtils.createHoodieWriteClient(sparkSession, basePath, Map.empty,
+        tableName.asInstanceOf[Option[String]])
+      val config = client.getConfig
+      val context = client.getEngineContext
+      val table = HoodieSparkTable.create(config, context)
+      val writer = LSMTimelineWriter.getInstance(config, table)
+      val txnManager = client.getTransactionManager
+
+      // Acquire the same state-change lock that TimelineArchiverV2 holds while it
+      // calls compactAndClean, so this procedure cannot race with a concurrent
+      // archival/compaction over the LSM timeline manifest.
+      val startInstant = HoodieInstantTimeGenerator.getCurrentInstantTimeStr
+      val startNanos = System.nanoTime()
+      txnManager.beginStateChange(HOption.empty(), HOption.empty())
+      try {
+        writer.compactAndClean(context)
+      } finally {
+        txnManager.endStateChange(HOption.empty())
+      }
+      val durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos)
+
+      Seq(Row(startInstant, durationMillis))
+    } finally {
+      if (client != null) {
+        client.close()
+      }
+    }
+  }
+
+  override def build: Procedure = new RunTimelineCompactionProcedure()
+}
+
+object RunTimelineCompactionProcedure {
+  val NAME: String = "run_timeline_compaction"
+
+  def builder: Supplier[ProcedureBuilder] = new Supplier[ProcedureBuilder] {
+    override def get(): RunTimelineCompactionProcedure = new RunTimelineCompactionProcedure()
+  }
+}
diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRunTimelineCompactionProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRunTimelineCompactionProcedure.scala
new file mode 100644
index 000000000000..0c5a485dcf77
--- /dev/null
+++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRunTimelineCompactionProcedure.scala
@@ -0,0 +1,100 @@
+/*
+ * 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
+
+class TestRunTimelineCompactionProcedure extends HoodieSparkProcedureTestBase {
+
+  test("Test Call run_timeline_compaction Procedure by Table") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      spark.sql(
+        s"""
+           |create table $tableName (
+           | id int,
+           | name string,
+           | price double,
+           | ts long
+           | ) using hudi
+           | location '${tmp.getCanonicalPath}'
+           | tblproperties (
+           |   primaryKey = 'id',
+           |   type = 'cow',
+           |   orderingFields = 'ts',
+           |   hoodie.metadata.enable = 'false',
+           |   hoodie.keep.min.commits = '2',
+           |   hoodie.keep.max.commits = '3',
+           |   hoodie.cleaner.commits.retained = '1',
+           |   hoodie.timeline.compaction.batch.size = '2'
+           | )
+           |""".stripMargin)
+
+      // Generate enough commits so that subsequent archive_commits calls produce
+      // multiple L0 archived timeline files, which is the input that the timeline
+      // compaction procedure will then merge.
+      for (i <- 1 to 8) {
+        spark.sql(s"insert into $tableName values($i, 'a$i', ${i * 10}, ${i * 1000})")
+      }
+      spark.sql(s"call archive_commits(table => '$tableName'" +
+        s", min_commits => 2, max_commits => 3, retain_commits => 1, enable_metadata => false)")
+
+      val result = spark.sql(s"call run_timeline_compaction(table => '$tableName')").collect()
+      assertResult(1)(result.length)
+      assert(result(0).getString(0) != null && result(0).getString(0).nonEmpty,
+        s"start_compaction_time should be a non-empty instant string, got: ${result(0).getString(0)}")
+      assert(result(0).getLong(1) >= 0L,
+        s"time_taken_in_millis should be non-negative, got: ${result(0).getLong(1)}")
+
+      // Calling the procedure again on a quiesced table should still succeed
+      // (it is effectively a no-op when there is nothing left to compact).
+      val resultIdempotent = spark.sql(s"call run_timeline_compaction(table => '$tableName')").collect()
+      assertResult(1)(resultIdempotent.length)
+    }
+  }
+
+  test("Test Call run_timeline_compaction Procedure by Path") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      spark.sql(
+        s"""
+           |create table $tableName (
+           | id int,
+           | name string,
+           | price double,
+           | ts long
+           | ) using hudi
+           | location '${tmp.getCanonicalPath}'
+           | tblproperties (
+           |   primaryKey = 'id',
+           |   type = 'cow',
+           |   orderingFields = 'ts',
+           |   hoodie.metadata.enable = 'false'
+           | )
+           |""".stripMargin)
+
+      spark.sql(s"insert into $tableName values(1, 'a1', 10, 1000)")
+
+      val result = spark.sql(
+        s"call run_timeline_compaction(path => '${tmp.getCanonicalPath}')").collect()
+      assertResult(1)(result.length)
+      assert(result(0).getString(0) != null && result(0).getString(0).nonEmpty)
+      assert(result(0).getLong(1) >= 0L)
+    }
+  }
+}