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 9a43cc1fd322 fix(spark): reuse one embedded timeline server across 
streaming sink micro-batches (#20412)
9a43cc1fd322 is described below

commit 9a43cc1fd322786d94b758298a35a4468367670e
Author: Sivabalan Narayanan <[email protected]>
AuthorDate: Mon Oct 26 00:33:11 2026 -0700

    fix(spark): reuse one embedded timeline server across streaming sink 
micro-batches (#20412)
    
    * fix(spark): reuse one embedded timeline server across streaming sink 
micro-batches
    
    HoodieStreamingSink now owns a single embedded timeline server per streaming
    query, hands it to the write client (non-owning, as StreamSync does), keeps 
the
    client open across micro-batches, and releases both when the query 
terminates
    via a StreamingQueryListener. Drops the HUDI-3636 override that disabled the
    timeline server for streaming writes.
    
    Fixes #20400
    
    * Address review: release failed-write heartbeats and harden sink cleanup
    
    - Stop the heartbeat of a failed write's instant when the caller owns the
      write client, so the instant can be rolled back under the lazy
      failed-writes policy while the client is reused.
    - Match the termination listener on the run id too, and release resources on
      a separate thread with a graceful shutdown of async table services.
    - Proceed without the embedded timeline server if it fails to start, as a
      client starting its own server does.
    
    * Fix streaming sink hang between reset and async service shutdown callbacks
    
    reset() waited for the async compaction/clustering services while holding 
the
    sink lock, and the services' shutdown callbacks called reset() and blocked 
on
    that lock, so the service executor never terminated. This hung the query 
when
    a micro-batch exhausted its retries with an async service running (also on
    master), and the graceful release on query termination.
    
    reset() now detaches a service before stopping it; a callback does nothing 
for
    a detached service and resets the sink on a separate thread when the service
    stopped on its own.
    
    * Address review: release failed-instant heartbeats on all write paths
    
    - Record the instant started by each write (upsert/insert, delete, delete
      partition, bulk insert as row) and stop its heartbeat on any failure when
      the caller owns the write client.
    - Register the sink's termination listener before acquiring the timeline
      server, so it is released even if the client fails to build.
    - Test the reset/async-callback deadlock by driving reset directly with an
      async service running, and add a failing delete case to the heartbeat 
tests.
    
    * fix(client): stop the heartbeat when commit startup fails
    
    ---------
    
    Co-authored-by: danny0405 <[email protected]>
---
 .../apache/hudi/client/BaseHoodieWriteClient.java  |  13 +-
 .../hudi/client/TestBaseHoodieWriteClient.java     |  50 +++++
 .../main/java/org/apache/hudi/DataSourceUtils.java |  14 +-
 .../org/apache/hudi/HoodieSparkSqlWriter.scala     | 101 ++++++++---
 .../org/apache/hudi/HoodieStreamingSink.scala      | 161 +++++++++++++---
 .../hudi/functional/TestStructuredStreaming.scala  | 202 ++++++++++++++++++++-
 6 files changed, 487 insertions(+), 54 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java
index 004f8a5dce32..d93108c35b1f 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java
@@ -1152,7 +1152,7 @@ public abstract class BaseHoodieWriteClient<T, I, K, O> 
extends BaseHoodieClient
         HoodieTimeline.COMMIT_ACTION, () -> 
tableServiceClient.rollbackFailedWrites(metaClient));
 
     txnManager.beginStateChange(Option.empty(), 
lastCompletedTxnAndMetadata.map(Pair::getLeft));
-    String instantTime;
+    String instantTime = null;
     HoodieInstant instant = null;
     try {
       instantTime = providedInstantTime.orElseGet(() -> 
createNewInstantTime(false));
@@ -1173,6 +1173,17 @@ public abstract class BaseHoodieWriteClient<T, I, K, O> 
extends BaseHoodieClient
         instant = metaClient.createNewInstant(State.REQUESTED, actionType, 
instantTime);
         metaClient.getActiveTimeline().createNewInstant(instant);
       }
+    } catch (RuntimeException | Error failure) {
+      // The caller does not know the allocated instant until startCommit 
returns. In particular, a
+      // failed timeline write must not leave its heartbeat running on a 
client reused by streaming.
+      if (instantTime != null && config.getFailedWritesCleanPolicy().isLazy()) 
{
+        try {
+          heartbeatClient.stop(instantTime);
+        } catch (RuntimeException cleanupFailure) {
+          failure.addSuppressed(cleanupFailure);
+        }
+      }
+      throw failure;
     } finally {
       txnManager.endStateChange(Option.ofNullable(instant));
     }
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestBaseHoodieWriteClient.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestBaseHoodieWriteClient.java
index 2c7fac462e68..107ad1c2b3e1 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestBaseHoodieWriteClient.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/TestBaseHoodieWriteClient.java
@@ -20,9 +20,11 @@ package org.apache.hudi.client;
 
 import org.apache.hudi.callback.common.WriteStatusValidator;
 import org.apache.hudi.client.embedded.EmbeddedTimelineService;
+import org.apache.hudi.client.heartbeat.HoodieHeartbeatClient;
 import org.apache.hudi.client.transaction.TransactionManager;
 import org.apache.hudi.common.engine.HoodieLocalEngineContext;
 import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.HoodieFailedWritesCleaningPolicy;
 import org.apache.hudi.common.model.HoodieRecord;
 import org.apache.hudi.common.model.HoodieTimelineTimeZone;
 import org.apache.hudi.common.model.MetaFieldsMode;
@@ -42,6 +44,7 @@ import 
org.apache.hudi.common.table.view.FileSystemViewStorageType;
 import org.apache.hudi.common.testutils.HoodieCommonTestHarness;
 import org.apache.hudi.common.testutils.HoodieTestUtils;
 import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieCleanConfig;
 import org.apache.hudi.config.HoodieIndexConfig;
 import org.apache.hudi.config.HoodieLockConfig;
 import org.apache.hudi.config.HoodieWriteConfig;
@@ -65,6 +68,7 @@ import org.junit.jupiter.params.provider.EnumSource;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.mockito.InOrder;
 import org.mockito.Mockito;
+import org.mockito.stubbing.Answer;
 
 import java.io.IOException;
 import java.time.Instant;
@@ -82,6 +86,7 @@ import java.util.stream.Stream;
 import static 
org.apache.hudi.common.testutils.HoodieTestUtils.getDefaultStorageConf;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
@@ -645,6 +650,51 @@ class TestBaseHoodieWriteClient extends 
HoodieCommonTestHarness {
     
inOrder.verify(transactionManager).endStateChange(Option.of(expectedInstant));
   }
 
+  @ParameterizedTest
+  @CsvSource({"commit,false", "commit,true", "replacecommit,false", 
"replacecommit,true"})
+  void testFailedStartCommitStopsOnlyItsHeartbeat(String action, boolean 
persistBeforeFailure) throws IOException {
+    initMetaClient();
+    HoodieWriteConfig writeConfig = 
HoodieWriteConfig.newBuilder().withPath(basePath)
+        .withEmbeddedTimelineServerEnabled(false)
+        
.withWriteConcurrencyMode(WriteConcurrencyMode.OPTIMISTIC_CONCURRENCY_CONTROL)
+        .withCleanConfig(HoodieCleanConfig.newBuilder()
+            
.withFailedWritesCleaningPolicy(HoodieFailedWritesCleaningPolicy.LAZY).build())
+        .build();
+    HoodieTableMetaClient failingMetaClient = Mockito.spy(metaClient);
+    HoodieActiveTimeline timeline = 
Mockito.spy(metaClient.getActiveTimeline());
+    Mockito.doReturn(timeline).when(failingMetaClient).getActiveTimeline();
+    String failedInstant = "20260101000000001";
+    String nextInstant = "20260101000000002";
+    HoodieException failure = new HoodieException("Injected timeline write 
failure");
+    Answer<Object> failTimelineWrite = invocation -> {
+      
assertTrue(HoodieHeartbeatClient.heartbeatExists(metaClient.getStorage(), 
basePath, failedInstant));
+      if (persistBeforeFailure) {
+        invocation.callRealMethod();
+      }
+      throw failure;
+    };
+    if (HoodieTimeline.REPLACE_COMMIT_ACTION.equals(action)) {
+      
Mockito.doAnswer(failTimelineWrite).when(timeline).createRequestedCommitWithReplaceMetadata(failedInstant,
 action);
+    } else {
+      
Mockito.doAnswer(failTimelineWrite).when(timeline).createNewInstant(Mockito.any(HoodieInstant.class));
+    }
+
+    try (TestWriteClient client = new TestWriteClient(writeConfig, 
mock(HoodieTable.class), Option.empty(),
+        mock(BaseHoodieTableServiceClient.class), 
mock(TransactionManager.class), mock(TimeGenerator.class))) {
+      // A reused client can have another live instant; failure cleanup must 
not stop all heartbeats.
+      client.getHeartbeatClient().start(nextInstant);
+      assertSame(failure, assertThrows(HoodieException.class,
+          () -> client.startCommit(Option.of(failedInstant), action, 
failingMetaClient)));
+      
assertFalse(HoodieHeartbeatClient.heartbeatExists(metaClient.getStorage(), 
basePath, failedInstant));
+      
assertTrue(HoodieHeartbeatClient.heartbeatExists(metaClient.getStorage(), 
basePath, nextInstant));
+      assertEquals(persistBeforeFailure, 
metaClient.reloadActiveTimeline().containsInstant(failedInstant));
+
+      assertEquals(nextInstant, client.startCommit(Option.of(nextInstant), 
action, metaClient));
+      
assertTrue(metaClient.reloadActiveTimeline().containsInstant(nextInstant));
+      client.releaseResources(nextInstant);
+    }
+  }
+
   private static class TestWriteClient extends BaseHoodieWriteClient<String, 
String, String, String> {
     private final HoodieTable<String, String, String, String> table;
 
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/DataSourceUtils.java
 
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/DataSourceUtils.java
index 7ec921717258..e5e977941618 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/DataSourceUtils.java
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/DataSourceUtils.java
@@ -63,6 +63,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BiFunction;
 import java.util.stream.Collectors;
 
 import scala.Tuple2;
@@ -186,6 +187,17 @@ public class DataSourceUtils {
 
   public static SparkRDDWriteClient createHoodieClient(JavaSparkContext jssc, 
String schemaStr, String basePath,
                                                        String tblName, 
Map<String, String> parameters) {
+    return createHoodieClient(jssc, schemaStr, basePath, tblName, parameters,
+        (context, writeConfig) -> new SparkRDDWriteClient<>(context, 
writeConfig));
+  }
+
+  /**
+   * Creates a write client, letting the caller construct it from the resolved 
write config. Callers that own
+   * resources shared across clients (e.g. an embedded timeline server) use 
this to hand them to the client.
+   */
+  public static SparkRDDWriteClient createHoodieClient(JavaSparkContext jssc, 
String schemaStr, String basePath,
+                                                       String tblName, 
Map<String, String> parameters,
+                                                       
BiFunction<HoodieSparkEngineContext, HoodieWriteConfig, SparkRDDWriteClient> 
clientFactory) {
     HoodieSparkEngineContext context = new HoodieSparkEngineContext(jssc);
     HoodieWriteConfig writeConfig = createHoodieConfig(schemaStr, basePath, 
tblName, parameters);
     // The write options carry the table config of an existing table, so only 
a single-field complex keygen table
@@ -198,7 +210,7 @@ public class DataSourceUtils {
         KeyGenUtils.recordComplexKeygenEncodingIfMissing(metaClient, 
writeConfig);
       }
     }
-    return new SparkRDDWriteClient<>(context, writeConfig);
+    return clientFactory.apply(context, writeConfig);
   }
 
   public static HoodieWriteResult doWriteOperation(SparkRDDWriteClient client, 
JavaRDD<HoodieRecord> hoodieRecords,
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 7f9ed3818eb7..d3dd41c29998 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
@@ -76,7 +76,7 @@ import org.apache.spark.sql.types.StructType
 import org.slf4j.LoggerFactory
 
 import java.util.concurrent.atomic.AtomicBoolean
-import java.util.function.BiConsumer
+import java.util.function.{BiConsumer, BiFunction}
 
 import scala.collection.JavaConverters._
 import scala.collection.mutable
@@ -87,7 +87,10 @@ object HoodieSparkSqlWriter {
   case class StreamingWriteParams(hoodieTableConfigOpt: 
Option[HoodieTableConfig] = Option.empty,
                                   asyncCompactionTriggerFn: 
Option[SparkRDDWriteClient[_] => Unit] = Option.empty,
                                   asyncClusteringTriggerFn: 
Option[SparkRDDWriteClient[_] => Unit] = Option.empty,
-                                  extraPreCommitFn: 
Option[BiConsumer[HoodieTableMetaClient, HoodieCommitMetadata]] = Option.empty)
+                                  extraPreCommitFn: 
Option[BiConsumer[HoodieTableMetaClient, HoodieCommitMetadata]] = Option.empty,
+                                  // When set, the caller constructs the write 
client from the resolved write config and owns
+                                  // its lifecycle: the writer does not close 
it after the write.
+                                  writeClientFactory: 
Option[(HoodieSparkEngineContext, HoodieWriteConfig) => SparkRDDWriteClient[_]] 
= Option.empty)
 
   /**
    * Controls whether incoming batch's schema's nullability constraints should 
be canonicalized
@@ -165,6 +168,9 @@ class HoodieSparkSqlWriterInternal {
   private var tableExists: Boolean = false
   private var asyncCompactionTriggerFnDefined: Boolean = false
   private var asyncClusteringTriggerFnDefined: Boolean = false
+  private var writeClientOwnedByCaller: Boolean = false
+  // instant started by the current write and the client that started it, to 
stop its heartbeat if the write fails
+  private var startedInstant: Option[(SparkRDDWriteClient[_], String)] = None
 
   def write(sqlContext: SQLContext,
             mode: SaveMode,
@@ -183,6 +189,7 @@ class HoodieSparkSqlWriterInternal {
 
       while (counter <= maxRetry && !succeeded) {
         try {
+          startedInstant = None
           toReturn = writeInternal(sqlContext, mode, optParams, sourceDf, 
streamingWritesParamsOpt, hoodieWriteClient,
             schemaFromCatalog = schemaFromCatalog)
           if (counter > 0) {
@@ -191,6 +198,7 @@ class HoodieSparkSqlWriterInternal {
           succeeded = true
         } catch {
           case e: HoodieWriteConflictException =>
+            releaseStartedInstant()
             val writeConcurrencyMode = 
optParams.getOrElse(HoodieWriteConfig.WRITE_CONCURRENCY_MODE.key(), 
HoodieWriteConfig.WRITE_CONCURRENCY_MODE.defaultValue())
             if (WriteConcurrencyMode.supportsMultiWriter(writeConcurrencyMode) 
&& counter < maxRetry) {
               counter += 1
@@ -198,6 +206,9 @@ class HoodieSparkSqlWriterInternal {
             } else {
               throw e
             }
+          case e: Throwable =>
+            releaseStartedInstant()
+            throw e
         }
       }
       toReturn
@@ -205,6 +216,29 @@ class HoodieSparkSqlWriterInternal {
     retryWrite()
   }
 
+  /**
+   * Stops the heartbeat of the instant a failed write started, when the 
caller owns the write client: the client
+   * lives on, so its heartbeat would otherwise keep the failed instant 
looking alive and block its rollback under
+   * the lazy failed-writes clean policy. A client the writer owns is closed 
instead, which stops all its heartbeats.
+   */
+  private def releaseStartedInstant(): Unit = {
+    if (writeClientOwnedByCaller) {
+      startedInstant.foreach { case (client, instant) =>
+        try {
+          client.releaseResources(instant)
+        } catch {
+          case e: Exception => log.warn(s"Failed to release the resources of 
instant $instant", e)
+        }
+      }
+    }
+    startedInstant = None
+  }
+
+  private def recordStartedInstant(client: SparkRDDWriteClient[_], instant: 
String): String = {
+    startedInstant = Some((client, instant))
+    instant
+  }
+
   private def writeInternal(sqlContext: SQLContext,
                             mode: SaveMode,
                             optParams: Map[String, String],
@@ -236,6 +270,7 @@ class HoodieSparkSqlWriterInternal {
 
     asyncCompactionTriggerFnDefined = 
streamingWritesParamsOpt.map(_.asyncCompactionTriggerFn.isDefined).orElse(Some(false)).get
     asyncClusteringTriggerFnDefined = 
streamingWritesParamsOpt.map(_.asyncClusteringTriggerFn.isDefined).orElse(Some(false)).get
+    writeClientOwnedByCaller = 
streamingWritesParamsOpt.exists(_.writeClientFactory.isDefined)
     // re-use table configs and inject defaults.
     var (parameters, hoodieConfig) = mergeParamsAndGetHoodieConfig(optParams, 
tableConfig, mode, streamingWritesParamsOpt.isDefined)
     val databaseName = 
hoodieConfig.getStringOrDefault(HoodieTableConfig.DATABASE_NAME, "")
@@ -409,9 +444,9 @@ class HoodieSparkSqlWriterInternal {
 
             // Create a HoodieWriteClient & issue the delete.
             val internalSchemaOpt = 
HoodieSchemaUtils.getLatestTableInternalSchema(hoodieConfig, tableMetaClient)
-            val client = 
hoodieWriteClient.getOrElse(DataSourceUtils.createHoodieClient(jsc,
+            val client = hoodieWriteClient.getOrElse(createWriteClient(jsc,
                 null, path, tblName,
-                addSchemaEvolutionParameters(parameters, 
internalSchemaOpt).asJava))
+                addSchemaEvolutionParameters(parameters, 
internalSchemaOpt).asJava, streamingWritesParamsOpt))
               .asInstanceOf[SparkRDDWriteClient[_]]
 
             if (isAsyncCompactionEnabled(client, tableConfig, parameters, 
jsc.hadoopConfiguration())) {
@@ -421,7 +456,7 @@ class HoodieSparkSqlWriterInternal {
               
streamingWritesParamsOpt.map(_.asyncClusteringTriggerFn.get.apply(client))
             }
 
-            instantTime = client.startCommit(commitActionType)
+            instantTime = recordStartedInstant(client, 
client.startCommit(commitActionType))
             // Ingestion setup may have recorded the encoding, and commit 
start may have upgraded the table.
             tableMetaClient.reloadTableConfig()
             val deleteKeyGenProps = 
KeyGenUtils.withComplexKeyGenEncoding(TypedProperties.copy(hoodieConfig.getProps),
 tableMetaClient.getTableConfig)
@@ -464,12 +499,12 @@ class HoodieSparkSqlWriterInternal {
 
             // Issue the delete.
             val schemaStr = new 
TableSchemaResolver(tableMetaClient).getTableSchema(false).toString
-            val client = 
hoodieWriteClient.getOrElse(DataSourceUtils.createHoodieClient(jsc,
+            val client = hoodieWriteClient.getOrElse(createWriteClient(jsc,
                 schemaStr, path, tblName,
-                parameters.asJava))
+                parameters.asJava, streamingWritesParamsOpt))
               .asInstanceOf[SparkRDDWriteClient[_]]
             // Issue delete partitions
-            instantTime = client.startDeletePartitionCommit(tableMetaClient)
+            instantTime = recordStartedInstant(client, 
client.startDeletePartitionCommit(tableMetaClient))
             val writeResult =  
DataSourceUtils.doDeletePartitionsOperation(client, partitionsToDelete, 
instantTime)
             (writeResult, client)
 
@@ -514,7 +549,7 @@ class HoodieSparkSqlWriterInternal {
             val client = hoodieWriteClient.getOrElse {
               val finalOpts = addSchemaEvolutionParameters(parameters, 
internalSchemaOpt, Some(writerSchema))
               // TODO(HUDI-4772) proper writer-schema has to be specified here
-              DataSourceUtils.createHoodieClient(jsc, 
processedDataSchema.toString, path, tblName, finalOpts.asJava)
+              createWriteClient(jsc, processedDataSchema.toString, path, 
tblName, finalOpts.asJava, streamingWritesParamsOpt)
             }
 
             if (isAsyncCompactionEnabled(client, tableConfig, parameters, 
jsc.hadoopConfiguration())) {
@@ -533,7 +568,7 @@ class HoodieSparkSqlWriterInternal {
             // scalastyle:on
 
             val writeConfig = client.getConfig
-            instantTime = client.startCommit(commitActionType)
+            instantTime = recordStartedInstant(client, 
client.startCommit(commitActionType))
             // Ingestion setup may have recorded the encoding, and commit 
start may have upgraded the table.
             tableMetaClient.reloadTableConfig()
             tableConfig = tableMetaClient.getTableConfig
@@ -561,7 +596,7 @@ class HoodieSparkSqlWriterInternal {
             } catch {
               case e: HoodieException =>
                 // close the write client in all cases
-                closeWriteClient(client, tableConfig, parameters, 
jsc.hadoopConfiguration())
+                closeWriteClient(client, instantTime, tableConfig, parameters, 
jsc.hadoopConfiguration())
                 throw e
             }
         }
@@ -576,17 +611,41 @@ class HoodieSparkSqlWriterInternal {
 
         (writeSuccessful, common.util.Option.ofNullable(instantTime), 
compactionInstant, clusteringInstant, writeClient, tableConfig)
       } finally {
-        closeWriteClient(writeClient, tableConfig, parameters, 
jsc.hadoopConfiguration())
+        closeWriteClient(writeClient, instantTime, tableConfig, parameters, 
jsc.hadoopConfiguration())
       }
     }
   }
 
-  private def closeWriteClient(writeClient: SparkRDDWriteClient[_], 
tableConfig: HoodieTableConfig, parameters: Map[String, String], configuration: 
Configuration): Unit = {
-    // close the write client in all cases
-    val asyncCompactionEnabled = isAsyncCompactionEnabled(writeClient, 
tableConfig, parameters, configuration)
-    val asyncClusteringEnabled = isAsyncClusteringEnabled(writeClient, 
parameters)
-    if (!asyncCompactionEnabled && !asyncClusteringEnabled) {
-      writeClient.close()
+  private def createWriteClient(jsc: JavaSparkContext, schemaStr: String, 
path: String, tblName: String,
+                                parameters: java.util.Map[String, String],
+                                streamingWritesParamsOpt: 
Option[StreamingWriteParams]): SparkRDDWriteClient[_] = {
+    streamingWritesParamsOpt.flatMap(_.writeClientFactory) match {
+      case Some(factory) =>
+        DataSourceUtils.createHoodieClient(jsc, schemaStr, path, tblName, 
parameters,
+          new BiFunction[HoodieSparkEngineContext, HoodieWriteConfig, 
SparkRDDWriteClient[_]] {
+            override def apply(context: HoodieSparkEngineContext, writeConfig: 
HoodieWriteConfig): SparkRDDWriteClient[_] =
+              factory(context, writeConfig)
+          })
+      case None =>
+        DataSourceUtils.createHoodieClient(jsc, schemaStr, path, tblName, 
parameters)
+    }
+  }
+
+  private def closeWriteClient(writeClient: SparkRDDWriteClient[_], 
instantTime: String, tableConfig: HoodieTableConfig,
+                               parameters: Map[String, String], configuration: 
Configuration): Unit = {
+    if (writeClientOwnedByCaller) {
+      // a caller-owned client (streaming sink) is reused across writes and 
closed by its owner. Stop the heartbeat
+      // of this write's instant in case it failed before commit (a no-op 
after a successful commit), so that the
+      // failed instant can be rolled back while the client lives on.
+      if (instantTime != null) {
+        writeClient.releaseResources(instantTime)
+      }
+    } else {
+      val asyncCompactionEnabled = isAsyncCompactionEnabled(writeClient, 
tableConfig, parameters, configuration)
+      val asyncClusteringEnabled = isAsyncClusteringEnabled(writeClient, 
parameters)
+      if (!asyncCompactionEnabled && !asyncClusteringEnabled) {
+        writeClient.close()
+      }
     }
   }
 
@@ -855,7 +914,7 @@ class HoodieSparkSqlWriterInternal {
       .orNull
 
     val commitActionType = 
CommitUtils.getCommitActionType(overwriteOperationType, 
writeConfig.getTableType)
-    val instantTime = writeClient.startCommit(commitActionType)
+    val instantTime = recordStartedInstant(writeClient, 
writeClient.startCommit(commitActionType))
     val executor = mode match {
       case _ if overwriteOperationType == null =>
         // Don't need to overwrite
@@ -876,7 +935,7 @@ class HoodieSparkSqlWriterInternal {
     } catch {
       case e: HoodieException =>
         // close the write client in all cases
-        closeWriteClient(writeClient, tableConfig, parameters, 
jsc.hadoopConfiguration())
+        closeWriteClient(writeClient, instantTime, tableConfig, parameters, 
jsc.hadoopConfiguration())
         throw e
     }
 
@@ -886,7 +945,7 @@ class HoodieSparkSqlWriterInternal {
         TableInstantInfo(basePath, instantTime, executor.getCommitActionType, 
executor.getWriteOperationType), Option.empty)
       (writeSuccessful, HOption.ofNullable(instantTime), compactionInstant, 
clusteringInstant, writeClient, tableConfig)
     } finally {
-      closeWriteClient(writeClient, tableConfig, parameters, 
jsc.hadoopConfiguration())
+      closeWriteClient(writeClient, instantTime, tableConfig, parameters, 
jsc.hadoopConfiguration())
     }
   }
 
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieStreamingSink.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieStreamingSink.scala
index ce16d82c33a3..7939cd301852 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieStreamingSink.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieStreamingSink.scala
@@ -22,11 +22,12 @@ import org.apache.hudi.HoodieStreamingSink.{QUERY_ID_KEY, 
SINK_CHECKPOINT_KEY}
 import org.apache.hudi.async.{AsyncClusteringService, AsyncCompactService, 
SparkStreamingAsyncClusteringService, SparkStreamingAsyncCompactService}
 import org.apache.hudi.client.SparkRDDWriteClient
 import org.apache.hudi.client.common.HoodieSparkEngineContext
+import org.apache.hudi.client.embedded.{EmbeddedTimelineServerHelper, 
EmbeddedTimelineService}
 import org.apache.hudi.common.model.HoodieCommitMetadata
 import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient}
 import org.apache.hudi.common.table.marker.MarkerType
 import org.apache.hudi.common.table.timeline.HoodieInstant
-import org.apache.hudi.common.util.{ClusteringUtils, CommitUtils, 
CompactionUtils}
+import org.apache.hudi.common.util.{ClusteringUtils, CommitUtils, 
CompactionUtils, Option => HOption}
 import org.apache.hudi.common.util.ValidationUtils.checkArgument
 import org.apache.hudi.config.HoodieWriteConfig
 import org.apache.hudi.exception.{HoodieCorruptedDataException, 
HoodieException, TableNotFoundException}
@@ -35,9 +36,11 @@ import org.apache.hudi.hadoop.fs.HadoopFSUtils
 import org.apache.spark.api.java.JavaSparkContext
 import org.apache.spark.sql.{DataFrame, SaveMode, SQLContext}
 import org.apache.spark.sql.execution.streaming.Sink
-import org.apache.spark.sql.streaming.OutputMode
+import org.apache.spark.sql.streaming.{OutputMode, StreamingQueryListener}
+import 
org.apache.spark.sql.streaming.StreamingQueryListener.{QueryProgressEvent, 
QueryStartedEvent, QueryTerminatedEvent}
 import org.slf4j.LoggerFactory
 
+import java.io.IOException
 import java.lang
 import java.util.function.{BiConsumer, Function}
 
@@ -79,8 +82,8 @@ class HoodieStreamingSink(sqlContext: SQLContext,
   private val disableCompaction = 
options.getOrElse(STREAMING_DISABLE_COMPACTION.key,
     STREAMING_DISABLE_COMPACTION.defaultValue).toBoolean
 
-  private var isAsyncCompactorServiceShutdownAbnormally = false
-  private var isAsyncClusteringServiceShutdownAbnormally = false
+  @volatile private var isAsyncCompactorServiceShutdownAbnormally = false
+  @volatile private var isAsyncClusteringServiceShutdownAbnormally = false
 
   private val mode =
     if (outputMode == OutputMode.Append()) {
@@ -89,9 +92,15 @@ class HoodieStreamingSink(sqlContext: SQLContext,
       SaveMode.Overwrite
     }
 
-  private var asyncCompactorService: AsyncCompactService = _
-  private var asyncClusteringService: AsyncClusteringService = _
+  // volatile: read without the sink lock by the services' shutdown callbacks
+  @volatile private var asyncCompactorService: AsyncCompactService = _
+  @volatile private var asyncClusteringService: AsyncClusteringService = _
   private var writeClient: Option[SparkRDDWriteClient[_]] = Option.empty
+  // Owned by the sink and handed to every write client it creates, so that 
one timeline server is reused across
+  // micro-batches (same as HoodieStreamer). Stopped on reset or when the 
streaming query terminates.
+  private var embeddedTimelineService: Option[EmbeddedTimelineService] = 
Option.empty
+  private var timelineServiceBasePath: String = _
+  private var queryTerminationListener: Option[StreamingQueryListener] = 
Option.empty
   private var hoodieTableConfig: Option[HoodieTableConfig] = Option.empty
 
   override def addBatch(batchId: Long, data: DataFrame): Unit = 
this.synchronized {
@@ -113,18 +122,11 @@ class HoodieStreamingSink(sqlContext: SQLContext,
     }
 
 
-    // Override to use direct markers. In Structured streaming, timeline 
server is closed after
-    // first micro-batch and subsequent micro-batches do not have timeline 
server running.
-    // Thus, we can't use timeline-server-based markers.
+    // Override to use direct markers.
     var updatedOptions = options.updated(HoodieWriteConfig.MARKERS_TYPE.key(), 
MarkerType.DIRECT.name())
     // we need auto adjustment enabled for streaming sink since async table 
services are feasible within the same JVM.
     updatedOptions = 
updatedOptions.updated(HoodieWriteConfig.AUTO_ADJUST_LOCK_CONFIGS.key, "true")
     updatedOptions = 
updatedOptions.updated(HoodieSparkSqlWriter.SPARK_STREAMING_BATCH_ID, 
batchId.toString)
-    if 
(!options.contains(HoodieWriteConfig.EMBEDDED_TIMELINE_SERVER_ENABLE.key())) {
-      // if user does not explicitly override, we are disabling timeline 
server for streaming sink.
-      // refer to HUDI-3636 for more details
-      updatedOptions = 
updatedOptions.updated(HoodieWriteConfig.EMBEDDED_TIMELINE_SERVER_ENABLE.key(), 
" false")
-    }
 
     retry(retryCnt, retryIntervalMs)(
       Try(
@@ -136,7 +138,8 @@ class HoodieStreamingSink(sqlContext: SQLContext,
                 val identifier = 
options.getOrElse(STREAMING_CHECKPOINT_IDENTIFIER.key(), 
STREAMING_CHECKPOINT_IDENTIFIER.defaultValue())
                 newCommitMetadata.addMetadata(SINK_CHECKPOINT_KEY, 
CommitUtils.getCheckpointValueAsString(identifier, String.valueOf(batchId)))
               }
-            }))), writeClient)
+            }),
+            writeClientFactory = Some((context, writeConfig) => 
createWriteClient(context, writeConfig)))), writeClient)
       )
       match {
         case Success((true, commitOps, compactionInstantOps, 
clusteringInstant, client, tableConfig)) =>
@@ -233,14 +236,14 @@ class HoodieStreamingSink(sqlContext: SQLContext,
   protected def triggerAsyncCompactor(client: SparkRDDWriteClient[_]): Unit = {
     if (null == asyncCompactorService) {
       log.info("Triggering Async compaction !!")
-      asyncCompactorService = new SparkStreamingAsyncCompactService(new 
HoodieSparkEngineContext(new JavaSparkContext(sqlContext.sparkContext)),
+      val compactorService = new SparkStreamingAsyncCompactService(new 
HoodieSparkEngineContext(new JavaSparkContext(sqlContext.sparkContext)),
         client)
-      asyncCompactorService.start(new Function[java.lang.Boolean, 
java.lang.Boolean] {
+      asyncCompactorService = compactorService
+      compactorService.start(new Function[java.lang.Boolean, 
java.lang.Boolean] {
         override def apply(errored: lang.Boolean): lang.Boolean = {
           log.info(s"Async Compactor shutdown. Errored ? $errored")
           isAsyncCompactorServiceShutdownAbnormally = errored
-          reset(false)
-          log.info("Done resetting write client.")
+          resetOnAsyncServiceStop(asyncCompactorService eq compactorService)
           true
         }
       })
@@ -263,13 +266,14 @@ class HoodieStreamingSink(sqlContext: SQLContext,
   protected def triggerAsyncClustering(client: SparkRDDWriteClient[_]): Unit = 
{
     if (null == asyncClusteringService) {
       log.info("Triggering async clustering!")
-      asyncClusteringService = new SparkStreamingAsyncClusteringService(new 
HoodieSparkEngineContext(new JavaSparkContext(sqlContext.sparkContext)),
+      val clusteringService = new SparkStreamingAsyncClusteringService(new 
HoodieSparkEngineContext(new JavaSparkContext(sqlContext.sparkContext)),
         client)
-      asyncClusteringService.start(new Function[java.lang.Boolean, 
java.lang.Boolean] {
+      asyncClusteringService = clusteringService
+      clusteringService.start(new Function[java.lang.Boolean, 
java.lang.Boolean] {
         override def apply(errored: lang.Boolean): lang.Boolean = {
           log.info(s"Async clustering service shutdown. Errored ? $errored")
           isAsyncClusteringServiceShutdownAbnormally = errored
-          reset(false)
+          resetOnAsyncServiceStop(asyncClusteringService eq clusteringService)
           true
         }
       })
@@ -288,21 +292,128 @@ class HoodieStreamingSink(sqlContext: SQLContext,
     }
   }
 
-  private def reset(force: Boolean): Unit = this.synchronized {
+  /**
+   * Creates the write client for this sink with the sink's embedded timeline 
server, starting the server on first
+   * use. The client does not own the server, so the server keeps running for 
as long as the sink does.
+   */
+  private def createWriteClient(context: HoodieSparkEngineContext, 
writeConfig: HoodieWriteConfig): SparkRDDWriteClient[_] = {
+    // register the release first, so that a timeline server started for a 
client that then fails to build is
+    // still stopped when the query terminates
+    registerQueryTerminationListener()
+    val client = new SparkRDDWriteClient(context, writeConfig, 
getOrStartTimelineService(context, writeConfig))
+    // track the client as soon as it exists so that a failed micro-batch 
still reuses and eventually closes it
+    writeClient = Some(client)
+    client
+  }
+
+  private def getOrStartTimelineService(context: HoodieSparkEngineContext,
+                                        writeConfig: HoodieWriteConfig): 
HOption[EmbeddedTimelineService] = {
+    if (!writeConfig.isEmbeddedTimelineServerEnabled) {
+      HOption.empty()
+    } else {
+      embeddedTimelineService match {
+        case Some(service) =>
+          
EmbeddedTimelineServerHelper.updateWriteConfigWithTimelineServer(service, 
writeConfig)
+        case None =>
+          try {
+            embeddedTimelineService = 
Some(EmbeddedTimelineServerHelper.createEmbeddedTimelineService(context, 
writeConfig))
+            timelineServiceBasePath = writeConfig.getBasePath
+          } catch {
+            case e: IOException =>
+              // same as a client that starts its own server: proceed as if 
the embedded server is disabled, and
+              // try again for the next write client
+              log.warn("Unable to start the embedded timeline server. 
Proceeding as if it is disabled", e)
+              
writeConfig.setValue(HoodieWriteConfig.EMBEDDED_TIMELINE_SERVER_ENABLE, "false")
+          }
+      }
+      embeddedTimelineService.map(service => 
HOption.of(service)).getOrElse(HOption.empty())
+    }
+  }
+
+  /**
+   * A V1 sink has no close hook, so release the write client and timeline 
server when the streaming query that
+   * owns this sink terminates. Otherwise they would live until the JVM exits, 
which matters for long-lived drivers
+   * (e.g. a Spark Connect server) that run many streaming queries.
+   */
+  private def registerQueryTerminationListener(): Unit = {
+    if (queryTerminationListener.isEmpty) {
+      val queryId = sqlContext.sparkContext.getLocalProperty(QUERY_ID_KEY)
+      // the query id is kept across restarts from the same checkpoint, so 
also match the run id: a late termination
+      // event of a previous run must not release the resources of this run
+      val runId = Option(queryId).flatMap(id => 
Option(sqlContext.sparkSession.streams.get(id))).map(_.runId.toString)
+      val listener = new StreamingQueryListener {
+        override def onQueryStarted(event: QueryStartedEvent): Unit = {}
+
+        override def onQueryProgress(event: QueryProgressEvent): Unit = {}
+
+        override def onQueryTerminated(event: QueryTerminatedEvent): Unit = {
+          if (event.id.toString == queryId && runId.forall(_ == 
event.runId.toString)) {
+            // release on a separate thread: the listener bus is shared by all 
queries of the session, and a graceful
+            // shutdown waits for in-flight async compaction / clustering 
before closing the client they use
+            val cleanup = new Thread(new Runnable {
+              override def run(): Unit = {
+                log.info(s"Streaming query $queryId terminated, closing the 
write client and timeline server")
+                reset(false)
+              }
+            }, s"hudi-streaming-sink-cleanup-$queryId")
+            cleanup.setDaemon(true)
+            cleanup.start()
+          }
+        }
+      }
+      sqlContext.sparkSession.streams.addListener(listener)
+      queryTerminationListener = Some(listener)
+    }
+  }
+
+  /**
+   * Called on an async table service's own thread when the service stops. 
[[reset]] detaches a service before
+   * shutting it down and waiting for it, so a service that is no longer 
attached was stopped by the sink and
+   * needs nothing more. A service that stopped on its own (e.g. it failed) 
resets the sink on a separate thread:
+   * resetting here would wait on the sink lock, and for this service's own 
executor to terminate.
+   */
+  private def resetOnAsyncServiceStop(stillAttached: Boolean): Unit = {
+    if (stillAttached) {
+      val resetThread = new Thread(new Runnable {
+        override def run(): Unit = {
+          reset(false)
+          log.info("Done resetting write client.")
+        }
+      }, "hudi-streaming-sink-reset")
+      resetThread.setDaemon(true)
+      resetThread.start()
+    }
+  }
+
+  protected def reset(force: Boolean): Unit = this.synchronized {
+    // detach each service before shutting it down, see resetOnAsyncServiceStop
     if (asyncCompactorService != null) {
-      asyncCompactorService.shutdown(force)
+      val compactorService = asyncCompactorService
       asyncCompactorService = null
+      compactorService.shutdown(force)
     }
 
     if (asyncClusteringService != null) {
-      asyncClusteringService.shutdown(force)
+      val clusteringService = asyncClusteringService
       asyncClusteringService = null
+      clusteringService.shutdown(force)
     }
 
     if (writeClient.isDefined) {
       writeClient.get.close()
       writeClient = Option.empty
     }
+
+    // stop the timeline server only after the client using it is closed
+    if (embeddedTimelineService.isDefined) {
+      embeddedTimelineService.get.stopForBasePath(timelineServiceBasePath)
+      embeddedTimelineService = Option.empty
+    }
+
+    if (queryTerminationListener.isDefined) {
+      
sqlContext.sparkSession.streams.removeListener(queryTerminationListener.get)
+      queryTerminationListener = Option.empty
+    }
   }
 
   private def canSkipBatch(incomingBatchId: Long, operationType: String): 
Boolean = {
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStructuredStreaming.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStructuredStreaming.scala
index ec0c5ff2ac09..125245adad48 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStructuredStreaming.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStructuredStreaming.scala
@@ -17,27 +17,32 @@
 
 package org.apache.hudi.functional
 
-import org.apache.hudi.{DataSourceReadOptions, DataSourceWriteOptions, 
HoodieDataSourceHelpers}
+import org.apache.hudi.{DataSourceReadOptions, DataSourceUtils, 
DataSourceWriteOptions, DefaultSparkRecordMerger, HoodieDataSourceHelpers, 
HoodieStreamingSink}
 import 
org.apache.hudi.DataSourceWriteOptions.{STREAMING_CHECKPOINT_IDENTIFIER, 
UPSERT_OPERATION_OPT_VAL}
 import org.apache.hudi.HoodieStreamingSink.SINK_CHECKPOINT_KEY
-import org.apache.hudi.common.config.HoodieStorageConfig
-import org.apache.hudi.common.model.{FileSlice, HoodieTableType, 
WriteConcurrencyMode}
+import org.apache.hudi.client.SparkRDDWriteClient
+import org.apache.hudi.common.config.{HoodieStorageConfig, TypedProperties}
+import org.apache.hudi.common.engine.RecordContext
+import org.apache.hudi.common.model.{FileSlice, 
HoodieFailedWritesCleaningPolicy, HoodieTableType, WriteConcurrencyMode}
 import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient}
+import org.apache.hudi.common.table.read.BufferedRecord
 import org.apache.hudi.common.table.timeline.HoodieTimeline
+import org.apache.hudi.common.table.view.{FileSystemViewStorageConfig, 
FileSystemViewStorageType}
 import org.apache.hudi.common.testutils.{HoodieTestDataGenerator, 
HoodieTestTable, HoodieTestUtils}
 import 
org.apache.hudi.common.testutils.HoodieTestDataGenerator.recordsToStrings
 import org.apache.hudi.common.util.{CollectionUtils, CommitUtils}
-import org.apache.hudi.config.{HoodieClusteringConfig, HoodieCompactionConfig, 
HoodieLockConfig, HoodieWriteConfig}
+import org.apache.hudi.config.{HoodieCleanConfig, HoodieClusteringConfig, 
HoodieCompactionConfig, HoodieLockConfig, HoodieWriteConfig}
 import org.apache.hudi.core.transaction.lock.InProcessLockProvider
-import org.apache.hudi.exception.TableNotFoundException
+import org.apache.hudi.exception.{HoodieException, TableNotFoundException}
 import org.apache.hudi.storage.{HoodieStorage, StoragePath}
 import org.apache.hudi.testutils.{DataSourceTestUtils, 
HoodieSparkClientTestBase, HoodieSparkDeleteRecordMerger}
 
 import org.apache.spark.sql._
+import org.apache.spark.sql.functions.lit
 import org.apache.spark.sql.streaming.{OutputMode, StreamingQuery, Trigger}
 import org.apache.spark.sql.types.StructType
 import org.junit.jupiter.api.{BeforeEach, Test}
-import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
 import org.junit.jupiter.params.ParameterizedTest
 import org.junit.jupiter.params.provider.{CsvSource, EnumSource, ValueSource}
 import org.slf4j.LoggerFactory
@@ -98,6 +103,18 @@ class TestStructuredStreaming extends 
HoodieSparkClientTestBase {
     (sourcePath, destPath)
   }
 
+  /**
+   * Lazy failed-writes cleaning, where failed instants are rolled back once 
their heartbeat expires; failed
+   * micro-batches are ignored so that the query keeps running.
+   */
+  private val lazyFailedWritesCleaningOpts = Map(
+    HoodieWriteConfig.WRITE_CONCURRENCY_MODE.key -> 
WriteConcurrencyMode.OPTIMISTIC_CONCURRENCY_CONTROL.name,
+    HoodieCleanConfig.FAILED_WRITES_CLEANER_POLICY.key -> 
HoodieFailedWritesCleaningPolicy.LAZY.name,
+    HoodieLockConfig.LOCK_PROVIDER_CLASS_NAME.key -> 
classOf[InProcessLockProvider].getName,
+    STREAMING_CHECKPOINT_IDENTIFIER.key -> "streaming_writer",
+    DataSourceWriteOptions.STREAMING_IGNORE_FAILED_BATCH.key -> "true",
+    DataSourceWriteOptions.STREAMING_RETRY_CNT.key -> "1")
+
   def getOptsWithTableType(tableType: HoodieTableType): Map[String, String] = {
     commonOpts + (DataSourceWriteOptions.TABLE_TYPE.key -> tableType.name())
   }
@@ -508,6 +525,144 @@ class TestStructuredStreaming extends 
HoodieSparkClientTestBase {
     assertEquals(25, metaClient.getActiveTimeline.countInstants())
   }
 
+  /**
+   * The sink owns one embedded timeline server for the lifetime of the 
streaming query and reuses it across
+   * micro-batches. With a REMOTE_ONLY file system view and no backup view, 
every micro-batch after the first used
+   * to fail because the timeline server was stopped along with the first 
micro-batch's write client
+   * (https://github.com/apache/hudi/issues/12652). Failed batches are ignored 
here so that a regression shows up
+   * as missing commits rather than the sink exiting the JVM.
+   */
+  @ParameterizedTest
+  @EnumSource(value = classOf[HoodieTableType])
+  def testStructuredStreamingReusesTimelineServerAcrossBatches(tableType: 
HoodieTableType): Unit = {
+    val (sourcePath, destPath) = initStreamingSourceAndDestPath("source", 
"dest")
+    val opts = getOptsWithTableType(tableType) ++ Map(
+      HoodieWriteConfig.EMBEDDED_TIMELINE_SERVER_ENABLE.key -> "true",
+      FileSystemViewStorageConfig.VIEW_TYPE.key -> 
FileSystemViewStorageType.REMOTE_ONLY.name,
+      FileSystemViewStorageConfig.REMOTE_BACKUP_VIEW_ENABLE.key -> "false",
+      DataSourceWriteOptions.STREAMING_IGNORE_FAILED_BATCH.key -> "true",
+      DataSourceWriteOptions.STREAMING_RETRY_CNT.key -> "1")
+
+    val records1 = recordsToStrings(dataGen.generateInserts("000", 
100)).asScala.toList
+    val inputDF1 = spark.read.json(spark.sparkContext.parallelize(records1, 2))
+    inputDF1.coalesce(1).write.mode(SaveMode.Append).json(sourcePath)
+    val query = initWritingStreamingQuery(inputDF1.schema, sourcePath, 
destPath, opts)
+    try {
+      query.processAllAvailable()
+      for (i <- 1 to 2) {
+        val records = 
recordsToStrings(dataGen.generateUpdates("%03d".format(i), 50)).asScala.toList
+        spark.read.json(spark.sparkContext.parallelize(records, 2)).coalesce(1)
+          .write.mode(SaveMode.Append).json(sourcePath)
+        query.processAllAvailable()
+      }
+    } finally {
+      query.stop()
+    }
+
+    assertEquals(3, 
HoodieDataSourceHelpers.allCompletedCommitsCompactions(storage, 
destPath).countInstants())
+  }
+
+  /**
+   * Same as [[testStructuredStreamingReleasesHeartbeatOfFailedBatch]] for a 
delete stream, which fails inside the
+   * delete operation: a record without a record key fails the key generation 
after the commit started.
+   */
+  @Test
+  def testStructuredStreamingReleasesHeartbeatOfFailedDelete(): Unit = {
+    val (sourcePath, destPath) = initStreamingSourceAndDestPath("source", 
"dest")
+    val opts = getOptsWithTableType(HoodieTableType.COPY_ON_WRITE) ++ 
lazyFailedWritesCleaningOpts
+    val inputDF = spark.read.json(spark.sparkContext.parallelize(
+      recordsToStrings(dataGen.generateInserts("000", 10)).asScala.toList, 1))
+    
inputDF.write.format("org.apache.hudi").options(opts).mode(SaveMode.Overwrite).save(destPath)
+
+    inputDF.limit(1).withColumn("_row_key", 
lit(null).cast("string")).write.mode(SaveMode.Append).json(sourcePath)
+    val query = initWritingStreamingQuery(inputDF.schema, sourcePath, destPath,
+      opts + (DataSourceWriteOptions.OPERATION.key -> 
DataSourceWriteOptions.DELETE_OPERATION_OPT_VAL))
+    try {
+      query.processAllAvailable()
+    } finally {
+      query.stop()
+    }
+
+    val failedInstant = HoodieTestUtils.createMetaClient(storage, 
destPath).getActiveTimeline
+      .getCommitsTimeline.filterInflightsAndRequested().lastInstant()
+    assertTrue(failedInstant.isPresent, "the failed micro-batch must leave an 
inflight instant")
+    assertFalse(storage.exists(new 
StoragePath(HoodieTableMetaClient.getHeartbeatFolderPath(destPath),
+      failedInstant.get().requestedTime())), "the heartbeat of the failed 
instant must be stopped")
+  }
+
+  /**
+   * The sink keeps its write client open across micro-batches, so a 
micro-batch that fails after its commit started
+   * must still stop that instant's heartbeat. Otherwise, with the lazy 
failed-writes clean policy, the failed instant
+   * looks alive for as long as the query runs and is never rolled back.
+   */
+  @Test
+  def testStructuredStreamingReleasesHeartbeatOfFailedBatch(): Unit = {
+    val (sourcePath, destPath) = initStreamingSourceAndDestPath("source", 
"dest")
+    val opts = getOptsWithTableType(HoodieTableType.COPY_ON_WRITE) ++ Map(
+      DataSourceWriteOptions.OPERATION.key -> UPSERT_OPERATION_OPT_VAL,
+      DataSourceWriteOptions.RECORD_MERGE_MODE.key -> "CUSTOM",
+      DataSourceWriteOptions.RECORD_MERGE_STRATEGY_ID.key -> 
FailingSparkRecordMerger.STRATEGY_ID,
+      DataSourceWriteOptions.RECORD_MERGE_IMPL_CLASSES.key -> 
classOf[FailingSparkRecordMerger].getName
+    ) ++ lazyFailedWritesCleaningOpts
+
+    val records1 = recordsToStrings(dataGen.generateInserts("000", 
100)).asScala.toList
+    val inputDF1 = spark.read.json(spark.sparkContext.parallelize(records1, 2))
+    inputDF1.coalesce(1).write.mode(SaveMode.Append).json(sourcePath)
+    val query = initWritingStreamingQuery(inputDF1.schema, sourcePath, 
destPath, opts)
+    try {
+      query.processAllAvailable()
+
+      // the updates of the next micro-batch fail while merging, after the 
commit started
+      FailingSparkRecordMerger.shouldFail = true
+      try {
+        spark.read.json(spark.sparkContext.parallelize(
+          recordsToStrings(dataGen.generateUniqueUpdates("001", 
50)).asScala.toList, 2)).coalesce(1)
+          .write.mode(SaveMode.Append).json(sourcePath)
+        query.processAllAvailable()
+      } finally {
+        FailingSparkRecordMerger.shouldFail = false
+      }
+      val failedInstant = HoodieTestUtils.createMetaClient(storage, 
destPath).getActiveTimeline
+        .getCommitsTimeline.filterInflightsAndRequested().lastInstant()
+      assertTrue(failedInstant.isPresent, "the failed micro-batch must leave 
an inflight instant")
+      assertFalse(storage.exists(new 
StoragePath(HoodieTableMetaClient.getHeartbeatFolderPath(destPath),
+        failedInstant.get().requestedTime())), "the heartbeat of the failed 
instant must be stopped")
+
+      // the query recovers on the next micro-batch
+      spark.read.json(spark.sparkContext.parallelize(
+        recordsToStrings(dataGen.generateUniqueUpdates("002", 
50)).asScala.toList, 2)).coalesce(1)
+        .write.mode(SaveMode.Append).json(sourcePath)
+      query.processAllAvailable()
+    } finally {
+      query.stop()
+    }
+    assertEquals(2, 
HoodieDataSourceHelpers.allCompletedCommitsCompactions(storage, 
destPath).countInstants())
+  }
+
+  /**
+   * When a micro-batch exhausts its retries, the sink resets while holding 
its lock: it shuts down the async table
+   * services and waits for them. The services' shutdown callbacks must not 
block on the sink in turn, otherwise the
+   * reset never finishes and the query hangs. The reset is driven directly 
here, with async clustering running,
+   * since a micro-batch that exhausts its retries exits the JVM.
+   */
+  @Test
+  def testSinkResetWithAsyncServiceRunningDoesNotHang(): Unit = {
+    val (_, destPath) = initStreamingSourceAndDestPath("source", "dest")
+    val opts = getOptsWithTableType(HoodieTableType.COPY_ON_WRITE)
+    
spark.read.json(spark.sparkContext.parallelize(recordsToStrings(dataGen.generateInserts("000",
 10)).asScala.toList, 1))
+      
.write.format("org.apache.hudi").options(opts).mode(SaveMode.Overwrite).save(destPath)
+
+    val sink = new ResettableStreamingSink(sqlContext, opts + ("path" -> 
destPath))
+    val client = DataSourceUtils.createHoodieClient(jsc, 
HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA, destPath,
+      commonOpts(HoodieWriteConfig.TBL_NAME.key), opts.asJava)
+    try {
+      sink.startAsyncClustering(client)
+      Await.result(Future(sink.resetHoldingSinkLock()), Duration(2, "minutes"))
+    } finally {
+      client.close()
+    }
+  }
+
   /**
    * Injects a failing micro-batch into [[HoodieStreamingSink]] by pointing 
the record key at a
    * non-existent column so that every Hudi write throws. With 
STREAMING_IGNORE_FAILED_BATCH
@@ -614,3 +769,38 @@ class TestStructuredStreaming extends 
HoodieSparkClientTestBase {
     }
   }
 }
+
+/**
+ * Event-time merger that fails every merge while 
[[FailingSparkRecordMerger.shouldFail]] is set, to fail a write
+ * after its commit started.
+ */
+class FailingSparkRecordMerger extends DefaultSparkRecordMerger {
+  override def getMergingStrategy: String = 
FailingSparkRecordMerger.STRATEGY_ID
+
+  override def merge[T](older: BufferedRecord[T], newer: BufferedRecord[T], 
recordContext: RecordContext[T],
+                        props: TypedProperties): BufferedRecord[T] = {
+    if (FailingSparkRecordMerger.shouldFail) {
+      throw new HoodieException("Injected merge failure")
+    }
+    super.merge(older, newer, recordContext, props)
+  }
+}
+
+object FailingSparkRecordMerger {
+  val STRATEGY_ID = "6b3a2b4e-2f4f-4f0c-9a55-3c3f7d0b8e21"
+  @volatile var shouldFail: Boolean = false
+}
+
+/**
+ * Streaming sink that exposes its async clustering service and reset to tests.
+ */
+class ResettableStreamingSink(sqlContext: SQLContext, options: Map[String, 
String])
+  extends HoodieStreamingSink(sqlContext, options, Seq.empty, 
OutputMode.Append) {
+
+  def startAsyncClustering(client: SparkRDDWriteClient[_]): Unit = 
triggerAsyncClustering(client)
+
+  // a micro-batch that exhausts its retries resets the sink from addBatch, 
which holds the sink lock
+  def resetHoldingSinkLock(): Unit = this.synchronized {
+    reset(false)
+  }
+}

Reply via email to