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 53d0b14389bd fix(spark): re-create the streaming sink write client
when the writer schema changes (#20415)
53d0b14389bd is described below
commit 53d0b14389bddd3d753c38d7635c09c21043491d
Author: Sivabalan Narayanan <[email protected]>
AuthorDate: Mon Oct 26 19:05:05 2026 -0700
fix(spark): re-create the streaming sink write client when the writer
schema changes (#20415)
* fix(spark): re-create the streaming sink write client when the writer
schema changes
The streaming sink reuses one write client across micro-batches, so the
client's write config keeps the schema of the micro-batch that created it.
When the writer schema changes under a running query (the table schema
evolves and set-null-for-missing-columns / reconcile is on, a type is
widened, or a delete stream sees a new table schema), later writes and their
commit metadata would use the stale schema.
Like HoodieStreamer, re-create the write client when the schema it was
created
with no longer matches the write's schema, for upserts, deletes and delete
partitions. The comparison uses a snapshot taken at client creation rather
than the live write config (compaction updates its schema) and ignores the
internal schema version id. As in HoodieStreamer, the sink hands the new
client to the async table services and closes the old one right away; the
sink-owned timeline server keeps running across the swap.
* fix(client): hand off async write clients between completed runs
---------
Co-authored-by: danny0405 <[email protected]>
---
.../apache/hudi/async/AsyncClusteringService.java | 12 +-
.../org/apache/hudi/async/AsyncCompactService.java | 13 +-
.../async/TestAsyncTableServiceClientHandoff.java | 152 +++++++++++++++++++++
.../org/apache/hudi/HoodieSparkSqlWriter.scala | 85 ++++++++++--
.../org/apache/hudi/HoodieStreamingSink.scala | 38 +++++-
.../hudi/functional/TestStructuredStreaming.scala | 87 +++++++++++-
6 files changed, 362 insertions(+), 25 deletions(-)
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java
index eebfa9303eb9..236a6c10e964 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncClusteringService.java
@@ -76,7 +76,9 @@ public abstract class AsyncClusteringService extends
HoodieAsyncTableService {
final String instant = fetchNextAsyncServiceInstant();
if (null != instant) {
log.info("Starting clustering for instant {}", instant);
- clusteringClient.cluster(instant);
+ synchronized (clusteringClient) {
+ clusteringClient.cluster(instant);
+ }
log.info("Finished clustering for instant {}", instant);
}
}
@@ -98,9 +100,11 @@ public abstract class AsyncClusteringService extends
HoodieAsyncTableService {
}
/**
- * Update the write client to be used for clustering.
+ * Switches clients between complete clustering runs. Once this returns, no
run uses the old client.
*/
- public synchronized void updateWriteClient(BaseHoodieWriteClient
writeClient) {
- this.clusteringClient.updateWriteClient(writeClient);
+ public void updateWriteClient(BaseHoodieWriteClient writeClient) {
+ synchronized (clusteringClient) {
+ clusteringClient.updateWriteClient(writeClient);
+ }
}
}
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java
index 9a5106c02151..6fb6514e56ec 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/async/AsyncCompactService.java
@@ -78,7 +78,9 @@ public abstract class AsyncCompactService extends
HoodieAsyncTableService {
if (null != instantTime) {
log.info("Starting Compaction for instant {}", instantTime);
- compactor.compact(instantTime);
+ synchronized (compactor) {
+ compactor.compact(instantTime);
+ }
log.info("Finished Compaction for instant {}", instantTime);
}
}
@@ -108,7 +110,12 @@ public abstract class AsyncCompactService extends
HoodieAsyncTableService {
return false;
}
- public synchronized void updateWriteClient(BaseHoodieWriteClient
writeClient) {
- this.compactor.updateWriteClient(writeClient);
+ /**
+ * Switches clients between complete compaction runs. Once this returns, no
run uses the old client.
+ */
+ public void updateWriteClient(BaseHoodieWriteClient writeClient) {
+ synchronized (compactor) {
+ compactor.updateWriteClient(writeClient);
+ }
}
}
diff --git
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/async/TestAsyncTableServiceClientHandoff.java
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/async/TestAsyncTableServiceClientHandoff.java
new file mode 100644
index 000000000000..3d8a8543ae7c
--- /dev/null
+++
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/async/TestAsyncTableServiceClientHandoff.java
@@ -0,0 +1,152 @@
+/*
+ * 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.hudi.async;
+
+import org.apache.hudi.client.BaseHoodieWriteClient;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockito.stubbing.Answer;
+
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+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.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class TestAsyncTableServiceClientHandoff {
+
+ @ParameterizedTest
+ @CsvSource({"true,false", "false,false", "true,true", "false,true"})
+ void testHandoffWaitsForActiveRun(boolean compact, boolean failRun) throws
Exception {
+ HoodieEngineContext context = mock(HoodieEngineContext.class);
+ BaseHoodieWriteClient oldClient = mock(BaseHoodieWriteClient.class);
+ BaseHoodieWriteClient newClient = mock(BaseHoodieWriteClient.class);
+ HoodieWriteConfig config = mock(HoodieWriteConfig.class);
+ when(oldClient.getConfig()).thenReturn(config);
+ when(config.areTableServicesEnabled()).thenReturn(true);
+ AtomicReference<BaseHoodieWriteClient> currentClient = new
AtomicReference<>(oldClient);
+ CountDownLatch entered = new CountDownLatch(1);
+ CountDownLatch finishRun = new CountDownLatch(1);
+ CountDownLatch nextRun = new CountDownLatch(1);
+ CountDownLatch stopped = new CountDownLatch(1);
+ Answer<Void> run = invocation -> {
+ BaseHoodieWriteClient capturedClient = currentClient.get();
+ if ("001".equals(invocation.getArgument(0))) {
+ entered.countDown();
+ assertTrue(finishRun.await(10, TimeUnit.SECONDS));
+ assertSame(oldClient, capturedClient);
+ verify(oldClient, never()).close();
+ if (failRun) {
+ throw new IOException("Injected async operation failure");
+ }
+ } else {
+ assertSame(newClient, capturedClient);
+ nextRun.countDown();
+ }
+ return null;
+ };
+ Answer<Void> update = invocation -> {
+ currentClient.set(invocation.getArgument(0));
+ return null;
+ };
+ HoodieAsyncService service;
+ Consumer<BaseHoodieWriteClient> handoff;
+ if (compact) {
+ BaseCompactor compactor = mock(BaseCompactor.class);
+ doAnswer(run).when(compactor).compact(anyString());
+ doAnswer(update).when(compactor).updateWriteClient(any());
+ AsyncCompactService compaction = new AsyncCompactService(context,
oldClient, true) {
+ @Override
+ protected BaseCompactor createCompactor(BaseHoodieWriteClient client) {
+ return compactor;
+ }
+ };
+ service = compaction;
+ handoff = compaction::updateWriteClient;
+ } else {
+ BaseClusterer clusterer = mock(BaseClusterer.class);
+ doAnswer(run).when(clusterer).cluster(anyString());
+ doAnswer(update).when(clusterer).updateWriteClient(any());
+ AsyncClusteringService clustering = new AsyncClusteringService(context,
oldClient, true) {
+ @Override
+ protected BaseClusterer createClusteringClient(BaseHoodieWriteClient
client) {
+ return clusterer;
+ }
+ };
+ service = clustering;
+ handoff = clustering::updateWriteClient;
+ }
+
+ ExecutorService swapExecutor = Executors.newSingleThreadExecutor();
+ try {
+ service.start(errored -> {
+ stopped.countDown();
+ return true;
+ });
+ service.enqueuePendingAsyncServiceInstant("001");
+ assertTrue(entered.await(10, TimeUnit.SECONDS));
+ CountDownLatch swapping = new CountDownLatch(1);
+ Future<?> swap = swapExecutor.submit(() -> {
+ swapping.countDown();
+ handoff.accept(newClient);
+ oldClient.close();
+ });
+ assertTrue(swapping.await(10, TimeUnit.SECONDS));
+ assertThrows(TimeoutException.class, () -> swap.get(200,
TimeUnit.MILLISECONDS));
+ verify(oldClient, never()).close();
+
+ finishRun.countDown();
+ swap.get(10, TimeUnit.SECONDS);
+ verify(oldClient).close();
+ if (failRun) {
+ assertTrue(stopped.await(10, TimeUnit.SECONDS));
+ assertTrue(service.hasError());
+ } else {
+ service.enqueuePendingAsyncServiceInstant("002");
+ assertTrue(nextRun.await(10, TimeUnit.SECONDS));
+ // An idle service must hand back the client promptly too, without
waiting for another run.
+ swapExecutor.submit(() -> handoff.accept(oldClient)).get(10,
TimeUnit.SECONDS);
+ }
+ } finally {
+ finishRun.countDown();
+ service.shutdown(true);
+ swapExecutor.shutdownNow();
+ assertTrue(swapExecutor.awaitTermination(10, TimeUnit.SECONDS));
+ }
+ }
+}
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 d3dd41c29998..9d1c2a6168d6 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
@@ -23,7 +23,7 @@ import
org.apache.hudi.DataSourceUtils.SparkDataSourceWriteStatusValidator
import org.apache.hudi.DataSourceWriteOptions._
import org.apache.hudi.HoodieConversionUtils.{toProperties, toScalaOption}
import
org.apache.hudi.HoodieSchemaConversionUtils.{convertStructTypeToHoodieSchema,
getRecordNameAndNamespace}
-import org.apache.hudi.HoodieSparkSqlWriter.StreamingWriteParams
+import org.apache.hudi.HoodieSparkSqlWriter.{StreamingWriteParams,
WriteClientSchema}
import org.apache.hudi.HoodieSparkSqlWriterInternal.{handleInsertDuplicates,
shouldDropDuplicatesForInserts, shouldFailWhenDuplicatesFound}
import org.apache.hudi.HoodieWriterUtils._
import org.apache.hudi.client.{HoodieWriteResult, SparkRDDWriteClient}
@@ -90,7 +90,21 @@ object HoodieSparkSqlWriter {
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)
+ writeClientFactory:
Option[(HoodieSparkEngineContext, HoodieWriteConfig) => SparkRDDWriteClient[_]]
= Option.empty,
+ // Schema the caller-owned write client was
created with. The client is reused only while
+ // it matches the schema of the write,
otherwise the factory creates a new one.
+ writeClientSchema: Option[WriteClientSchema]
= Option.empty)
+
+ /**
+ * Writer schema and internal schema of a write config, captured when a
write client is created. The write config
+ * itself is not a stable record of them, since table services running on
the client update its schema.
+ */
+ case class WriteClientSchema(schema: String, internalSchema: String)
+
+ object WriteClientSchema {
+ def of(writeConfig: HoodieWriteConfig): WriteClientSchema =
+ WriteClientSchema(StringUtils.nullToEmpty(writeConfig.getSchema),
StringUtils.nullToEmpty(writeConfig.getInternalSchema))
+ }
/**
* Controls whether incoming batch's schema's nullability constraints should
be canonicalized
@@ -444,9 +458,16 @@ class HoodieSparkSqlWriterInternal {
// Create a HoodieWriteClient & issue the delete.
val internalSchemaOpt =
HoodieSchemaUtils.getLatestTableInternalSchema(hoodieConfig, tableMetaClient)
- val client = hoodieWriteClient.getOrElse(createWriteClient(jsc,
- null, path, tblName,
- addSchemaEvolutionParameters(parameters,
internalSchemaOpt).asJava, streamingWritesParamsOpt))
+ val deleteOpts = addSchemaEvolutionParameters(parameters,
internalSchemaOpt)
+ // A client that is reused across writes gets the latest table
schema upfront, so that a later change of the
+ // table schema is detected. Otherwise the client sets it on its
first delete.
+ val deleteSchemaStr = if (writeClientOwnedByCaller) {
+ new
TableSchemaResolver(tableMetaClient).getTableSchemaIfPresent(false).map[String](s
=> s.toString).orElse(null)
+ } else {
+ null
+ }
+ val client = reusableWriteClient(hoodieWriteClient,
streamingWritesParamsOpt, deleteSchemaStr, deleteOpts)
+ .getOrElse(createWriteClient(jsc, deleteSchemaStr, path,
tblName, deleteOpts.asJava, streamingWritesParamsOpt))
.asInstanceOf[SparkRDDWriteClient[_]]
if (isAsyncCompactionEnabled(client, tableConfig, parameters,
jsc.hadoopConfiguration())) {
@@ -499,9 +520,8 @@ class HoodieSparkSqlWriterInternal {
// Issue the delete.
val schemaStr = new
TableSchemaResolver(tableMetaClient).getTableSchema(false).toString
- val client = hoodieWriteClient.getOrElse(createWriteClient(jsc,
- schemaStr, path, tblName,
- parameters.asJava, streamingWritesParamsOpt))
+ val client = reusableWriteClient(hoodieWriteClient,
streamingWritesParamsOpt, schemaStr, parameters)
+ .getOrElse(createWriteClient(jsc, schemaStr, path, tblName,
parameters.asJava, streamingWritesParamsOpt))
.asInstanceOf[SparkRDDWriteClient[_]]
// Issue delete partitions
instantTime = recordStartedInstant(client,
client.startDeletePartitionCommit(tableMetaClient))
@@ -546,11 +566,12 @@ class HoodieSparkSqlWriterInternal {
}
// Create a HoodieWriteClient & issue the write.
- val client = hoodieWriteClient.getOrElse {
- val finalOpts = addSchemaEvolutionParameters(parameters,
internalSchemaOpt, Some(writerSchema))
- // TODO(HUDI-4772) proper writer-schema has to be specified here
- createWriteClient(jsc, processedDataSchema.toString, path,
tblName, finalOpts.asJava, streamingWritesParamsOpt)
- }
+ val finalOpts = addSchemaEvolutionParameters(parameters,
internalSchemaOpt, Some(writerSchema))
+ val client = reusableWriteClient(hoodieWriteClient,
streamingWritesParamsOpt, processedDataSchema.toString, finalOpts)
+ .getOrElse {
+ // TODO(HUDI-4772) proper writer-schema has to be specified
here
+ createWriteClient(jsc, processedDataSchema.toString, path,
tblName, finalOpts.asJava, streamingWritesParamsOpt)
+ }
if (isAsyncCompactionEnabled(client, tableConfig, parameters,
jsc.hadoopConfiguration())) {
streamingWritesParamsOpt.map(_.asyncCompactionTriggerFn.get.apply(client))
@@ -631,6 +652,44 @@ class HoodieSparkSqlWriterInternal {
}
}
+ /**
+ * Returns the write client passed in by the caller if it can serve a write
with the given schema. A caller-owned
+ * client (streaming sink) is reused across writes, but carries the schema
of the write that created it: once the
+ * schema changed (e.g. the table schema evolved under a streaming query),
the caller's factory creates a new
+ * client instead, as HoodieStreamer does on a schema change. Otherwise the
write and its commit metadata would use
+ * the stale schema.
+ */
+ private def reusableWriteClient(hoodieWriteClient:
Option[SparkRDDWriteClient[_]],
+ streamingWritesParamsOpt:
Option[StreamingWriteParams],
+ schemaStr: String,
+ writeOpts: Map[String, String]):
Option[SparkRDDWriteClient[_]] = {
+ if (!writeClientOwnedByCaller) {
+ hoodieWriteClient
+ } else {
+ hoodieWriteClient.filter { _ =>
+ val requiredSchema =
WriteClientSchema(StringUtils.nullToEmpty(schemaStr),
+
StringUtils.nullToEmpty(writeOpts.getOrElse(HoodieWriteConfig.INTERNAL_SCHEMA_STRING.key,
null)))
+ val clientSchema =
streamingWritesParamsOpt.flatMap(_.writeClientSchema)
+ val reusable = clientSchema.exists(current => current.schema ==
requiredSchema.schema
+ && isSameInternalSchema(current.internalSchema,
requiredSchema.internalSchema))
+ if (!reusable) {
+ log.info(s"Writer schema changed (schema hash
${clientSchema.map(_.schema.hashCode)} -> "
+ + s"${requiredSchema.schema.hashCode}), re-creating the write
client")
+ }
+ reusable
+ }
+ }
+ }
+
+ private def isSameInternalSchema(current: String, required: String): Boolean
= {
+ // the version id of an internal schema is set to the instant that saved
it, so it changes on every commit
+ current == required || {
+ val currentSchema = SerDeHelper.fromJson(current)
+ val requiredSchema = SerDeHelper.fromJson(required)
+ currentSchema.isPresent && requiredSchema.isPresent &&
currentSchema.get.equalsIgnoringVersion(requiredSchema.get)
+ }
+ }
+
private def closeWriteClient(writeClient: SparkRDDWriteClient[_],
instantTime: String, tableConfig: HoodieTableConfig,
parameters: Map[String, String], configuration:
Configuration): Unit = {
if (writeClientOwnedByCaller) {
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 e4f8f223d4e1..237c800fba60 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
@@ -17,7 +17,7 @@
package org.apache.hudi
import org.apache.hudi.DataSourceWriteOptions._
-import org.apache.hudi.HoodieSparkSqlWriter.StreamingWriteParams
+import org.apache.hudi.HoodieSparkSqlWriter.{StreamingWriteParams,
WriteClientSchema}
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
@@ -95,6 +95,8 @@ class HoodieStreamingSink(sqlContext: SQLContext,
@volatile private var asyncCompactorService: AsyncCompactService = _
@volatile private var asyncClusteringService: AsyncClusteringService = _
private var writeClient: Option[SparkRDDWriteClient[_]] = Option.empty
+ // schema the current write client was created with; the writer re-creates
the client when it changes
+ private var writeClientSchema: Option[WriteClientSchema] = 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
@@ -138,7 +140,8 @@ class HoodieStreamingSink(sqlContext: SQLContext,
newCommitMetadata.addMetadata(SINK_CHECKPOINT_KEY,
CommitUtils.getCheckpointValueAsString(identifier, String.valueOf(batchId)))
}
}),
- writeClientFactory = Some((context, writeConfig) =>
createWriteClient(context, writeConfig)))), writeClient)
+ writeClientFactory = Some((context, writeConfig) =>
createWriteClient(context, writeConfig)),
+ writeClientSchema = writeClientSchema)), writeClient)
)
match {
case Success((true, commitOps, compactionInstantOps,
clusteringInstant, client, tableConfig)) =>
@@ -299,12 +302,40 @@ class HoodieStreamingSink(sqlContext: SQLContext,
// 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 clientSchema = WriteClientSchema.of(writeConfig)
val client = new SparkRDDWriteClient(context, writeConfig,
getOrStartTimelineService(context, writeConfig))
+ val previousClient = writeClient
// track the client as soon as it exists so that a failed micro-batch
still reuses and eventually closes it
writeClient = Some(client)
+ writeClientSchema = Some(clientSchema)
+ // the writer re-creates the client when the write schema changes (as
HoodieStreamer does): hand the new client
+ // to the async table services and close the previous one. Its timeline
server is the sink's and keeps running.
+ previousClient.foreach(retireWriteClient(_, client))
client
}
+ /**
+ * Each handoff waits for the service's current run before switching to the
new client. Close the previous
+ * client only after both services have handed it back, without retaining
clients until query termination.
+ */
+ private def retireWriteClient(previousClient: SparkRDDWriteClient[_],
newClient: SparkRDDWriteClient[_]): Unit = {
+ if (asyncCompactorService != null) {
+ asyncCompactorService.updateWriteClient(newClient)
+ }
+ if (asyncClusteringService != null) {
+ asyncClusteringService.updateWriteClient(newClient)
+ }
+ closeQuietly(previousClient)
+ }
+
+ private def closeQuietly(client: SparkRDDWriteClient[_]): Unit = {
+ try {
+ client.close()
+ } catch {
+ case e: Exception => log.warn("Failed to close a write client of the
streaming sink", e)
+ }
+ }
+
private def getOrStartTimelineService(context: HoodieSparkEngineContext,
writeConfig: HoodieWriteConfig):
HOption[EmbeddedTimelineService] = {
if (!writeConfig.isEmbeddedTimelineServerEnabled) {
@@ -401,9 +432,10 @@ class HoodieStreamingSink(sqlContext: SQLContext,
if (writeClient.isDefined) {
writeClient.get.close()
writeClient = Option.empty
+ writeClientSchema = Option.empty
}
- // stop the timeline server only after the client using it is closed
+ // stop the timeline server only after the clients using it are closed
if (embeddedTimelineService.isDefined) {
embeddedTimelineService.get.stopForBasePath(timelineServiceBasePath)
embeddedTimelineService = Option.empty
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 e4fe503622b2..e4c95355e917 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
@@ -24,7 +24,7 @@ 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.{HoodieTableConfig, HoodieTableMetaClient,
TableSchemaResolver}
import org.apache.hudi.common.table.marker.MarkerType
import org.apache.hudi.common.table.read.BufferedRecord
import org.apache.hudi.common.table.timeline.HoodieTimeline
@@ -39,7 +39,7 @@ 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.functions.{col, lit}
import org.apache.spark.sql.streaming.{OutputMode, StreamingQuery, Trigger}
import org.apache.spark.sql.types.StructType
import org.junit.jupiter.api.{BeforeEach, Test}
@@ -563,6 +563,89 @@ class TestStructuredStreaming extends
HoodieSparkClientTestBase {
assertEquals(3,
HoodieDataSourceHelpers.allCompletedCommitsCompactions(storage,
destPath).countInstants())
}
+ /**
+ * The sink reuses its write client across micro-batches, so it must
re-create the client when the writer schema
+ * changes under a running query, as HoodieStreamer does. Here a batch
writer adds a column between two
+ * micro-batches; the next micro-batch fills it with nulls and must commit
with the evolved schema.
+ */
+ @ParameterizedTest
+ @EnumSource(value = classOf[HoodieTableType])
+ def testStructuredStreamingWithTableSchemaEvolvedMidQuery(tableType:
HoodieTableType): Unit = {
+ val (sourcePath, destPath) = initStreamingSourceAndDestPath("source",
"dest")
+ val opts = getOptsWithTableType(tableType) ++ Map(
+ DataSourceWriteOptions.SET_NULL_FOR_MISSING_COLUMNS.key -> "true")
+
+ 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)
+ val evolvedDF = spark.read.json(spark.sparkContext.parallelize(
+ recordsToStrings(dataGen.generateUniqueUpdates("001",
10)).asScala.toList, 2))
+ .withColumn("new_col", lit("evolved"))
+ val streamedDF = spark.read.json(spark.sparkContext.parallelize(
+ recordsToStrings(dataGen.generateUniqueUpdates("002",
50)).asScala.toList, 2))
+ try {
+ query.processAllAvailable()
+ // evolve the table schema outside of the streaming query
+ evolvedDF.write.format("org.apache.hudi").options(opts)
+ .option(DataSourceWriteOptions.OPERATION.key, UPSERT_OPERATION_OPT_VAL)
+ .mode(SaveMode.Append).save(destPath)
+ streamedDF.coalesce(1).write.mode(SaveMode.Append).json(sourcePath)
+ query.processAllAvailable()
+ } finally {
+ query.stop()
+ }
+
+ val metaClient = HoodieTestUtils.createMetaClient(storage, destPath)
+ assertEquals(3,
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants().countInstants())
+ val tableSchema = new TableSchemaResolver(metaClient).getTableSchema(false)
+ assertTrue(tableSchema.getField("new_col").isPresent, s"new_col missing
from table schema: $tableSchema")
+ val tableDF = spark.read.format("org.apache.hudi").load(destPath)
+ assertEquals(100, tableDF.count())
+ // rows last written by the batch writer keep their value, rows the stream
updated afterwards are null
+ val evolvedOnlyKeys =
evolvedDF.select("_row_key").except(streamedDF.select("_row_key")).count()
+ assertEquals(evolvedOnlyKeys, tableDF.filter(col("new_col") ===
"evolved").count())
+ }
+
+ /**
+ * Same as [[testStructuredStreamingWithTableSchemaEvolvedMidQuery]] for a
delete stream: delete commits record
+ * the schema of the write client, so a stale client would drop the new
column from the table schema.
+ */
+ @Test
+ def testStructuredStreamingDeletesWithTableSchemaEvolvedMidQuery(): Unit = {
+ val (sourcePath, destPath) = initStreamingSourceAndDestPath("source",
"dest")
+ val opts = getOptsWithTableType(HoodieTableType.COPY_ON_WRITE)
+ val records = recordsToStrings(dataGen.generateInserts("000",
100)).asScala.toList
+ val inputDF = spark.read.json(spark.sparkContext.parallelize(records, 2))
+
inputDF.write.format("org.apache.hudi").options(opts).mode(SaveMode.Overwrite).save(destPath)
+
+ def recordsDF(from: Int, until: Int): DataFrame =
+
spark.read.schema(inputDF.schema).json(spark.sparkContext.parallelize(records.slice(from,
until), 1))
+
+ recordsDF(0, 10).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()
+ // evolve the table schema outside of the streaming query
+ recordsDF(20, 30).withColumn("new_col",
lit("evolved")).write.format("org.apache.hudi").options(opts)
+ .option(DataSourceWriteOptions.OPERATION.key, UPSERT_OPERATION_OPT_VAL)
+ .mode(SaveMode.Append).save(destPath)
+ recordsDF(10, 20).write.mode(SaveMode.Append).json(sourcePath)
+ query.processAllAvailable()
+ } finally {
+ query.stop()
+ }
+
+ val metaClient = HoodieTestUtils.createMetaClient(storage, destPath)
+ assertEquals(4,
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants().countInstants())
+ val tableSchema = new TableSchemaResolver(metaClient).getTableSchema(false)
+ assertTrue(tableSchema.getField("new_col").isPresent, s"new_col missing
from table schema: $tableSchema")
+ val tableDF = spark.read.format("org.apache.hudi").load(destPath)
+ assertEquals(80, tableDF.count())
+ assertEquals(10, tableDF.filter(col("new_col") === "evolved").count())
+ }
+
/**
* 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.