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 da78989d9377 fix(kafka-connect): terminate transaction coordinator
event loop on stop (#20271)
da78989d9377 is described below
commit da78989d9377c6603b1ef63bf5c82256266c2325
Author: fhan <[email protected]>
AuthorDate: Tue Oct 13 17:19:05 2026 +0800
fix(kafka-connect): terminate transaction coordinator event loop on stop
(#20271)
* fix(kafka-connect): terminate transaction coordinator event loop on stop
* fix(kafka-connect): clean up coordinator resources on shutdown
* fix(kafka-connect): retry coordinator service cleanup on repeated stop
---------
Co-authored-by: fhan <[email protected]>
---
.../transaction/ConnectTransactionCoordinator.java | 121 +++++++---
.../writers/ConnectTransactionServices.java | 5 +-
.../writers/KafkaConnectTransactionServices.java | 5 +
...TestConnectTransactionCoordinatorLifecycle.java | 251 +++++++++++++++++++++
.../helper/MockConnectTransactionServices.java | 4 +
5 files changed, 358 insertions(+), 28 deletions(-)
diff --git
a/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/transaction/ConnectTransactionCoordinator.java
b/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/transaction/ConnectTransactionCoordinator.java
index b3bd854a1e7e..7ab4bb3a040c 100644
---
a/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/transaction/ConnectTransactionCoordinator.java
+++
b/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/transaction/ConnectTransactionCoordinator.java
@@ -20,6 +20,7 @@ package org.apache.hudi.connect.transaction;
import org.apache.hudi.client.WriteStatus;
import org.apache.hudi.common.util.StringUtils;
+import org.apache.hudi.common.util.VisibleForTesting;
import org.apache.hudi.connect.ControlMessage;
import org.apache.hudi.connect.kafka.KafkaControlAgent;
import org.apache.hudi.connect.utils.KafkaConnectUtils;
@@ -42,6 +43,7 @@ import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -63,6 +65,7 @@ public class ConnectTransactionCoordinator implements
TransactionCoordinator, Ru
private static final Long START_COMMIT_INIT_DELAY_MS = 100L;
private static final Long RESTART_COMMIT_DELAY_MS = 500L;
private static final int COORDINATOR_EVENT_LOOP_TIMEOUT_MS = 1000;
+ private static final int COORDINATOR_EVENT_LOOP_SHUTDOWN_TIMEOUT_MS = 5000;
private final KafkaConnectConfigs configs;
@Getter
@@ -70,9 +73,11 @@ public class ConnectTransactionCoordinator implements
TransactionCoordinator, Ru
private final KafkaControlAgent kafkaControlClient;
private final ConnectTransactionServices transactionServices;
private final KafkaPartitionProvider partitionProvider;
+ private final int eventLoopShutdownTimeoutMs;
private final Map<Integer, List<WriteStatus>> partitionsWriteStatusReceived;
private final Map<Integer, Long> currentConsumedKafkaOffsets;
private final AtomicBoolean hasStarted = new AtomicBoolean(false);
+ private final AtomicBoolean transactionServicesClosed = new
AtomicBoolean(false);
private final BlockingQueue<CoordinatorEvent> events;
private final ExecutorService executorService;
private final ScheduledExecutorService scheduler;
@@ -81,6 +86,8 @@ public class ConnectTransactionCoordinator implements
TransactionCoordinator, Ru
private Map<Integer, Long> globalCommittedKafkaOffsets;
private State currentState;
private int numPartitions;
+ private boolean deregistered;
+ private boolean shutdownInitiated;
public ConnectTransactionCoordinator(KafkaConnectConfigs configs,
TopicPartition partition,
@@ -97,11 +104,23 @@ public class ConnectTransactionCoordinator implements
TransactionCoordinator, Ru
KafkaControlAgent kafkaControlClient,
ConnectTransactionServices
transactionServices,
KafkaPartitionProvider
partitionProvider) {
+ this(configs, partition, kafkaControlClient, transactionServices,
partitionProvider,
+ COORDINATOR_EVENT_LOOP_SHUTDOWN_TIMEOUT_MS);
+ }
+
+ @VisibleForTesting
+ ConnectTransactionCoordinator(KafkaConnectConfigs configs,
+ TopicPartition partition,
+ KafkaControlAgent kafkaControlClient,
+ ConnectTransactionServices transactionServices,
+ KafkaPartitionProvider partitionProvider,
+ int eventLoopShutdownTimeoutMs) {
this.configs = configs;
this.partition = partition;
this.kafkaControlClient = kafkaControlClient;
this.transactionServices = transactionServices;
this.partitionProvider = partitionProvider;
+ this.eventLoopShutdownTimeoutMs = eventLoopShutdownTimeoutMs;
this.events = new LinkedBlockingQueue<>();
scheduler = Executors.newSingleThreadScheduledExecutor();
executorService = Executors.newSingleThreadExecutor();
@@ -131,25 +150,20 @@ public class ConnectTransactionCoordinator implements
TransactionCoordinator, Ru
}
@Override
- public void stop() {
- kafkaControlClient.deregisterTransactionCoordinator(this);
- scheduler.shutdownNow();
+ public synchronized void stop() {
hasStarted.set(false);
- if (executorService != null) {
- boolean terminated = false;
- try {
- log.info("Shutting down executor service.");
- executorService.shutdown();
- log.info("Awaiting termination.");
- terminated = executorService.awaitTermination(100,
TimeUnit.MILLISECONDS);
- } catch (InterruptedException e) {
- // ignored
+ try {
+ if (!deregistered) {
+ kafkaControlClient.deregisterTransactionCoordinator(this);
+ deregistered = true;
}
-
- if (!terminated) {
- log.warn(
- "Unclean Kafka Control Manager executor service shutdown ");
- executorService.shutdownNow();
+ } finally {
+ if (!shutdownInitiated) {
+ shutdownInitiated = true;
+ shutdownEventLoop();
+ }
+ if (executorService.isTerminated()) {
+ closeTransactionServices();
}
}
}
@@ -173,14 +187,54 @@ public class ConnectTransactionCoordinator implements
TransactionCoordinator, Ru
@Override
public void run() {
- while (true) {
- try {
- CoordinatorEvent event =
events.poll(COORDINATOR_EVENT_LOOP_TIMEOUT_MS, TimeUnit.MILLISECONDS);
- if (event != null) {
- processCoordinatorEvent(event);
+ try {
+ while (hasStarted.get()) {
+ try {
+ CoordinatorEvent event =
events.poll(COORDINATOR_EVENT_LOOP_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ if (event != null && hasStarted.get()) {
+ processCoordinatorEvent(event);
+ }
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ log.debug("Transaction coordinator event loop interrupted for topic
{} partition {}",
+ partition.topic(), partition.partition());
+ break;
}
- } catch (InterruptedException exception) {
- log.warn("Error received while polling the event loop in Partition
Coordinator", exception);
+ }
+ } finally {
+ closeTransactionServices();
+ }
+ }
+
+ @VisibleForTesting
+ boolean isEventLoopTerminated() {
+ return executorService.isTerminated();
+ }
+
+ private void shutdownEventLoop() {
+ scheduler.shutdownNow();
+ events.clear();
+ executorService.shutdownNow();
+ try {
+ if (!executorService.awaitTermination(eventLoopShutdownTimeoutMs,
TimeUnit.MILLISECONDS)) {
+ log.warn("Transaction coordinator event loop did not terminate within
{} ms for topic {} partition {}",
+ eventLoopShutdownTimeoutMs, partition.topic(),
partition.partition());
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.warn("Interrupted while waiting for transaction coordinator event
loop to terminate for topic {} partition {}",
+ partition.topic(), partition.partition());
+ }
+ }
+
+ private void closeTransactionServices() {
+ if (transactionServicesClosed.compareAndSet(false, true)) {
+ try {
+ transactionServices.close();
+ } catch (RuntimeException e) {
+ transactionServicesClosed.set(false);
+ log.warn("Failed to close transaction services for topic {} partition
{}",
+ partition.topic(), partition.partition(), e);
}
}
}
@@ -190,9 +244,22 @@ public class ConnectTransactionCoordinator implements
TransactionCoordinator, Ru
}
private void submitEvent(CoordinatorEvent event, long delay, TimeUnit unit) {
- scheduler.schedule(() -> {
- events.add(event);
- }, delay, unit);
+ if (!hasStarted.get()) {
+ return;
+ }
+ try {
+ scheduler.schedule(() -> {
+ if (hasStarted.get()) {
+ events.add(event);
+ }
+ }, delay, unit);
+ } catch (RejectedExecutionException e) {
+ if (hasStarted.get()) {
+ throw e;
+ }
+ log.debug("Ignoring coordinator event submitted during shutdown for
topic {} partition {}",
+ partition.topic(), partition.partition());
+ }
}
private void processCoordinatorEvent(CoordinatorEvent event) {
diff --git
a/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/ConnectTransactionServices.java
b/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/ConnectTransactionServices.java
index 2ce44ff802bc..62f00d5d80d2 100644
---
a/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/ConnectTransactionServices.java
+++
b/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/ConnectTransactionServices.java
@@ -28,11 +28,14 @@ import java.util.Map;
* Transaction service APIs used by
* {@link TransactionCoordinator}.
*/
-public interface ConnectTransactionServices {
+public interface ConnectTransactionServices extends AutoCloseable {
String startCommit();
boolean endCommit(String commitTime, List<WriteStatus> writeStatuses,
Map<String, String> extraMetadata);
Map<String, String> fetchLatestExtraCommitMetadata();
+
+ @Override
+ void close();
}
diff --git
a/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/KafkaConnectTransactionServices.java
b/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/KafkaConnectTransactionServices.java
index 21c76f5aa8c6..6b89627fad9c 100644
---
a/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/KafkaConnectTransactionServices.java
+++
b/hudi-kafka-connect/src/main/java/org/apache/hudi/connect/writers/KafkaConnectTransactionServices.java
@@ -151,6 +151,11 @@ public class KafkaConnectTransactionServices implements
ConnectTransactionServic
throw new HoodieException("Fatal error retrieving Hoodie Extra Metadata
since Table Meta Client is absent");
}
+ @Override
+ public void close() {
+ javaClient.close();
+ }
+
private boolean isAsyncCompactionEnabled() {
return tableMetaClient.isPresent()
&&
HoodieTableType.MERGE_ON_READ.equals(tableMetaClient.get().getTableType())
diff --git
a/hudi-kafka-connect/src/test/java/org/apache/hudi/connect/transaction/TestConnectTransactionCoordinatorLifecycle.java
b/hudi-kafka-connect/src/test/java/org/apache/hudi/connect/transaction/TestConnectTransactionCoordinatorLifecycle.java
new file mode 100644
index 000000000000..3967c583defa
--- /dev/null
+++
b/hudi-kafka-connect/src/test/java/org/apache/hudi/connect/transaction/TestConnectTransactionCoordinatorLifecycle.java
@@ -0,0 +1,251 @@
+/*
+ * 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.connect.transaction;
+
+import org.apache.hudi.connect.ControlMessage;
+import org.apache.hudi.connect.kafka.KafkaControlAgent;
+import org.apache.hudi.connect.writers.ConnectTransactionServices;
+import org.apache.hudi.connect.writers.KafkaConnectConfigs;
+
+import org.apache.kafka.common.TopicPartition;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class TestConnectTransactionCoordinatorLifecycle {
+
+ private static final String TOPIC_NAME = "coordinator-lifecycle-test-topic";
+
+ @Test
+ void stopTerminatesEventLoopAndIgnoresFurtherEvents() throws Exception {
+ KafkaControlAgent controlAgent = mock(KafkaControlAgent.class);
+ ConnectTransactionServices transactionServices =
mock(ConnectTransactionServices.class);
+ CountDownLatch eventProcessed = new CountDownLatch(1);
+
when(transactionServices.fetchLatestExtraCommitMetadata()).thenReturn(Collections.emptyMap());
+ when(transactionServices.startCommit()).thenAnswer(invocation -> {
+ eventProcessed.countDown();
+ return "001";
+ });
+
+ KafkaConnectConfigs configs = KafkaConnectConfigs.newBuilder()
+ .withCommitIntervalSecs(60L)
+ .withCoordinatorWriteTimeoutSecs(60L)
+ .build();
+ ConnectTransactionCoordinator coordinator = new
ConnectTransactionCoordinator(
+ configs,
+ new TopicPartition(TOPIC_NAME,
ConnectTransactionCoordinator.COORDINATOR_KAFKA_PARTITION),
+ controlAgent,
+ transactionServices,
+ (bootstrapServers, topicName) -> 1);
+
+ try {
+ coordinator.start();
+ assertTrue(eventProcessed.await(5, TimeUnit.SECONDS));
+
+ coordinator.stop();
+
+ assertTrue(coordinator.isEventLoopTerminated());
+ assertDoesNotThrow(coordinator::stop);
+ assertDoesNotThrow(() ->
coordinator.processControlEvent(writeStatusEvent()));
+ verify(controlAgent,
times(1)).deregisterTransactionCoordinator(coordinator);
+ verify(transactionServices, times(1)).close();
+ } finally {
+ coordinator.stop();
+ }
+ }
+
+ @Test
+ void stopClosesTransactionServicesAfterEventProcessingExits() throws
Exception {
+ KafkaControlAgent controlAgent = mock(KafkaControlAgent.class);
+ ConnectTransactionServices transactionServices =
mock(ConnectTransactionServices.class);
+ CountDownLatch eventProcessingStarted = new CountDownLatch(1);
+ CountDownLatch releaseEventProcessing = new CountDownLatch(1);
+ AtomicBoolean eventProcessingExited = new AtomicBoolean(false);
+ AtomicBoolean servicesClosedAfterEventProcessing = new
AtomicBoolean(false);
+
when(transactionServices.fetchLatestExtraCommitMetadata()).thenReturn(Collections.emptyMap());
+ when(transactionServices.startCommit()).thenAnswer(invocation -> {
+ eventProcessingStarted.countDown();
+ try {
+ releaseEventProcessing.await();
+ } finally {
+ eventProcessingExited.set(true);
+ }
+ return "001";
+ });
+ doAnswer(invocation -> {
+ servicesClosedAfterEventProcessing.set(eventProcessingExited.get());
+ return null;
+ }).when(transactionServices).close();
+
+ ConnectTransactionCoordinator coordinator =
createCoordinator(controlAgent, transactionServices);
+ try {
+ coordinator.start();
+ assertTrue(eventProcessingStarted.await(5, TimeUnit.SECONDS));
+
+ coordinator.stop();
+
+ assertTrue(eventProcessingExited.get());
+ assertTrue(servicesClosedAfterEventProcessing.get());
+ verify(transactionServices, times(1)).close();
+ } finally {
+ releaseEventProcessing.countDown();
+ coordinator.stop();
+ }
+ }
+
+ @Test
+ void stopRetriesTransactionServicesCleanupAfterDelayedEventLoopExit() throws
Exception {
+ KafkaControlAgent controlAgent = mock(KafkaControlAgent.class);
+ ConnectTransactionServices transactionServices =
mock(ConnectTransactionServices.class);
+ CountDownLatch eventProcessingStarted = new CountDownLatch(1);
+ CountDownLatch releaseEventProcessing = new CountDownLatch(1);
+ CountDownLatch firstCloseAttempted = new CountDownLatch(1);
+ AtomicBoolean eventProcessingInterrupted = new AtomicBoolean(false);
+ AtomicBoolean failFirstClose = new AtomicBoolean(true);
+
when(transactionServices.fetchLatestExtraCommitMetadata()).thenReturn(Collections.emptyMap());
+ when(transactionServices.startCommit()).thenAnswer(invocation -> {
+ eventProcessingStarted.countDown();
+ boolean released = false;
+ while (!released) {
+ try {
+ releaseEventProcessing.await();
+ released = true;
+ } catch (InterruptedException e) {
+ eventProcessingInterrupted.set(true);
+ }
+ }
+ return "001";
+ });
+ doAnswer(invocation -> {
+ if (failFirstClose.compareAndSet(true, false)) {
+ firstCloseAttempted.countDown();
+ throw new IllegalStateException("close failed");
+ }
+ return null;
+ }).when(transactionServices).close();
+
+ ConnectTransactionCoordinator coordinator =
createCoordinator(controlAgent, transactionServices, 100);
+ try {
+ coordinator.start();
+ assertTrue(eventProcessingStarted.await(5, TimeUnit.SECONDS));
+
+ coordinator.stop();
+
+ assertTrue(eventProcessingInterrupted.get());
+ assertFalse(coordinator.isEventLoopTerminated());
+ verify(transactionServices, never()).close();
+
+ releaseEventProcessing.countDown();
+ assertTrue(firstCloseAttempted.await(5, TimeUnit.SECONDS));
+ waitForEventLoopTermination(coordinator);
+ verify(transactionServices, times(1)).close();
+
+ assertDoesNotThrow(coordinator::stop);
+ verify(transactionServices, times(2)).close();
+ } finally {
+ releaseEventProcessing.countDown();
+ coordinator.stop();
+ }
+ }
+
+ @Test
+ void stopRetriesDeregistrationAfterFailure() throws Exception {
+ KafkaControlAgent controlAgent = mock(KafkaControlAgent.class);
+ ConnectTransactionServices transactionServices =
mock(ConnectTransactionServices.class);
+ CountDownLatch eventProcessed = new CountDownLatch(1);
+
when(transactionServices.fetchLatestExtraCommitMetadata()).thenReturn(Collections.emptyMap());
+ when(transactionServices.startCommit()).thenAnswer(invocation -> {
+ eventProcessed.countDown();
+ return "001";
+ });
+
+ ConnectTransactionCoordinator coordinator =
createCoordinator(controlAgent, transactionServices);
+ try {
+ coordinator.start();
+ assertTrue(eventProcessed.await(5, TimeUnit.SECONDS));
+ doThrow(new IllegalStateException("deregistration failed"))
+ .doNothing()
+ .when(controlAgent).deregisterTransactionCoordinator(coordinator);
+
+ assertThrows(IllegalStateException.class, coordinator::stop);
+ assertTrue(coordinator.isEventLoopTerminated());
+ assertDoesNotThrow(coordinator::stop);
+ assertDoesNotThrow(coordinator::stop);
+
+ verify(controlAgent,
times(2)).deregisterTransactionCoordinator(coordinator);
+ verify(transactionServices, times(1)).close();
+ } finally {
+ coordinator.stop();
+ }
+ }
+
+ private static ConnectTransactionCoordinator createCoordinator(
+ KafkaControlAgent controlAgent, ConnectTransactionServices
transactionServices) {
+ return createCoordinator(controlAgent, transactionServices, 5000);
+ }
+
+ private static ConnectTransactionCoordinator createCoordinator(
+ KafkaControlAgent controlAgent,
+ ConnectTransactionServices transactionServices,
+ int eventLoopShutdownTimeoutMs) {
+ KafkaConnectConfigs configs = KafkaConnectConfigs.newBuilder()
+ .withCommitIntervalSecs(60L)
+ .withCoordinatorWriteTimeoutSecs(60L)
+ .build();
+ return new ConnectTransactionCoordinator(
+ configs,
+ new TopicPartition(TOPIC_NAME,
ConnectTransactionCoordinator.COORDINATOR_KAFKA_PARTITION),
+ controlAgent,
+ transactionServices,
+ (bootstrapServers, topicName) -> 1,
+ eventLoopShutdownTimeoutMs);
+ }
+
+ private static void
waitForEventLoopTermination(ConnectTransactionCoordinator coordinator)
+ throws InterruptedException {
+ long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (!coordinator.isEventLoopTerminated() && System.nanoTime() <
deadlineNanos) {
+ TimeUnit.MILLISECONDS.sleep(10);
+ }
+ assertTrue(coordinator.isEventLoopTerminated());
+ }
+
+ private static ControlMessage writeStatusEvent() {
+ return ControlMessage.newBuilder()
+ .setType(ControlMessage.EventType.WRITE_STATUS)
+ .setTopicName(TOPIC_NAME)
+ .setCommitTime("001")
+ .build();
+ }
+}
diff --git
a/hudi-kafka-connect/src/test/java/org/apache/hudi/helper/MockConnectTransactionServices.java
b/hudi-kafka-connect/src/test/java/org/apache/hudi/helper/MockConnectTransactionServices.java
index b3314ade3d5b..5f23ed8b6531 100644
---
a/hudi-kafka-connect/src/test/java/org/apache/hudi/helper/MockConnectTransactionServices.java
+++
b/hudi-kafka-connect/src/test/java/org/apache/hudi/helper/MockConnectTransactionServices.java
@@ -55,4 +55,8 @@ public class MockConnectTransactionServices implements
ConnectTransactionService
public Map<String, String> fetchLatestExtraCommitMetadata() {
return new HashMap<>();
}
+
+ @Override
+ public void close() {
+ }
}