This is an automated email from the ASF dual-hosted git repository.
pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 73eb46b01a8 NIFI-16308 Prevent concurrent Connector starts (#11638)
73eb46b01a8 is described below
commit 73eb46b01a853cd9b8c7795f0c3370c798b422be
Author: Mark Payne <[email protected]>
AuthorDate: Mon Sep 7 14:29:16 2026 -0400
NIFI-16308 Prevent concurrent Connector starts (#11638)
---
.../connector/ConnectorStateTransition.java | 2 +-
.../connector/StandardConnectorNode.java | 55 +++++----
.../StandardConnectorStateTransition.java | 68 +++++++----
.../connector/TestStandardConnectorNode.java | 125 ++++++++++++++++++++-
.../TestStandardConnectorStateTransition.java | 38 +++++++
5 files changed, 240 insertions(+), 48 deletions(-)
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorStateTransition.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorStateTransition.java
index a9693f8eafc..c0c965946b0 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorStateTransition.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorStateTransition.java
@@ -64,7 +64,7 @@ public interface ConnectorStateTransition {
void setCurrentState(ConnectorState newState);
/**
- * Registers a future to be completed when the connector transitions to
the RUNNING state.
+ * Completes a future immediately when the connector is running, or
registers it to be completed when the connector transitions to the RUNNING
state.
* This method is thread-safe and handles internal synchronization.
*
* @param future the CompletableFuture to complete when the connector
starts
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java
index 1e0ae929d4c..06b87484e8e 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java
@@ -679,30 +679,41 @@ public class StandardConnectorNode implements
ConnectorNode, GroupedComponent {
verifyCanStart();
- final ConnectorState currentState = getCurrentState();
- switch (currentState) {
- case STARTING -> {
- logger.debug("{} is already starting; adding future to
pending start futures", this);
- stateTransition.addPendingStartFuture(startCompleteFuture);
- }
- case RUNNING -> {
- logger.debug("{} is already {}; will not attempt to
start", this, currentState);
- startCompleteFuture.complete(null);
- }
- case STOPPING -> {
- // We have set the Desired State to RUNNING so when the
Connector fully stops, it will be started again automatically
- logger.info("{} is currently stopping so will not trigger
Connector to start until it has fully stopped", this);
- stateTransition.addPendingStartFuture(startCompleteFuture);
- }
- case STOPPED, PREPARING_FOR_UPDATE, UPDATED -> {
- stateTransition.setCurrentState(ConnectorState.STARTING);
- scheduler.schedule(() -> startComponent(scheduler,
startCompleteFuture), 0, TimeUnit.SECONDS);
- }
- default -> {
- logger.warn("{} is in state {} and cannot be started",
this, currentState);
- stateTransition.addPendingStartFuture(startCompleteFuture);
+ boolean startScheduled = false;
+ while (!startScheduled) {
+ final ConnectorState currentState = getCurrentState();
+ switch (currentState) {
+ case STARTING -> {
+ logger.debug("{} is already starting; adding future to
pending start futures", this);
+
stateTransition.addPendingStartFuture(startCompleteFuture);
+ return;
+ }
+ case RUNNING -> {
+ logger.debug("{} is already {}; will not attempt to
start", this, currentState);
+ startCompleteFuture.complete(null);
+ return;
+ }
+ case STOPPING -> {
+ // We have set the Desired State to RUNNING so when
the Connector fully stops, it will be started again automatically
+ logger.info("{} is currently stopping so will not
trigger Connector to start until it has fully stopped", this);
+
stateTransition.addPendingStartFuture(startCompleteFuture);
+ return;
+ }
+ case STOPPED, PREPARING_FOR_UPDATE, UPDATED -> {
+ startScheduled =
stateTransition.trySetCurrentState(currentState, ConnectorState.STARTING);
+ if (startScheduled) {
+ logger.info("Starting {}", this);
+ }
+ }
+ default -> {
+ logger.warn("{} is in state {} and cannot be started",
this, currentState);
+
stateTransition.addPendingStartFuture(startCompleteFuture);
+ return;
+ }
}
}
+
+ scheduler.schedule(() -> startComponent(scheduler,
startCompleteFuture), 0, TimeUnit.SECONDS);
} catch (final Exception e) {
logger.error("Failed to start {}", this, e);
startCompleteFuture.completeExceptionally(e);
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorStateTransition.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorStateTransition.java
index 9cfd4cccb38..3e45c73bc3d 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorStateTransition.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorStateTransition.java
@@ -56,25 +56,47 @@ public class StandardConnectorStateTransition implements
ConnectorStateTransitio
@Override
public boolean trySetCurrentState(final ConnectorState expectedState,
final ConnectorState newState) {
- final boolean changed = currentState.compareAndSet(expectedState,
newState);
- if (changed) {
- logger.info("Transitioned current state for {} from {} to {}",
componentDescription, expectedState, newState);
- completeFuturesForStateTransition(newState);
+ final List<CompletableFuture<Void>> futuresToComplete;
+ synchronized (this) {
+ final boolean changed = currentState.compareAndSet(expectedState,
newState);
+ if (!changed) {
+ return false;
+ }
+
+ futuresToComplete = removePendingFutures(newState);
}
- return changed;
+ logger.info("Transitioned current state for {} from {} to {}",
componentDescription, expectedState, newState);
+ completeFutures(futuresToComplete, newState);
+ return true;
}
@Override
public void setCurrentState(final ConnectorState newState) {
- final ConnectorState oldState = currentState.getAndSet(newState);
+ final ConnectorState oldState;
+ final List<CompletableFuture<Void>> futuresToComplete;
+ synchronized (this) {
+ oldState = currentState.getAndSet(newState);
+ futuresToComplete = removePendingFutures(newState);
+ }
+
logger.info("Transitioned current state for {} from {} to {}",
componentDescription, oldState, newState);
- completeFuturesForStateTransition(newState);
+ completeFutures(futuresToComplete, newState);
}
@Override
- public synchronized void addPendingStartFuture(final
CompletableFuture<Void> future) {
- pendingStartFutures.add(future);
+ public void addPendingStartFuture(final CompletableFuture<Void> future) {
+ final boolean completeImmediately;
+ synchronized (this) {
+ completeImmediately = currentState.get() == ConnectorState.RUNNING;
+ if (!completeImmediately) {
+ pendingStartFutures.add(future);
+ }
+ }
+
+ if (completeImmediately) {
+ future.complete(null);
+ }
}
@Override
@@ -82,31 +104,29 @@ public class StandardConnectorStateTransition implements
ConnectorStateTransitio
pendingStopFutures.add(future);
}
- private synchronized void completeFuturesForStateTransition(final
ConnectorState newState) {
+ private List<CompletableFuture<Void>> removePendingFutures(final
ConnectorState newState) {
if (newState == ConnectorState.RUNNING) {
final List<CompletableFuture<Void>> futuresToComplete = new
ArrayList<>(pendingStartFutures);
pendingStartFutures.clear();
-
- for (final CompletableFuture<Void> future : futuresToComplete) {
- future.complete(null);
- }
-
- if (!futuresToComplete.isEmpty()) {
- logger.debug("Completed {} pending start futures for {}",
futuresToComplete.size(), componentDescription);
- }
+ return futuresToComplete;
}
if (newState == ConnectorState.STOPPED) {
final List<CompletableFuture<Void>> futuresToComplete = new
ArrayList<>(pendingStopFutures);
pendingStopFutures.clear();
+ return futuresToComplete;
+ }
- for (final CompletableFuture<Void> future : futuresToComplete) {
- future.complete(null);
- }
+ return List.of();
+ }
- if (!futuresToComplete.isEmpty()) {
- logger.debug("Completed {} pending stop futures for {}",
futuresToComplete.size(), componentDescription);
- }
+ private void completeFutures(final List<CompletableFuture<Void>> futures,
final ConnectorState newState) {
+ for (final CompletableFuture<Void> future : futures) {
+ future.complete(null);
+ }
+
+ if (!futures.isEmpty()) {
+ logger.debug("Completed {} pending futures for {} on transition to
{}", futures.size(), componentDescription, newState);
}
}
}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java
index 767c20a5abc..f96b2e9af75 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java
@@ -65,9 +65,12 @@ import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
+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.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -231,6 +234,32 @@ public class TestStandardConnectorNode {
assertTrue(startFuture2.isDone());
}
+ @Test
+ @Timeout(value = 10, unit = TimeUnit.SECONDS)
+ public void testConcurrentStartRequestsInvokeConnectorStartOnce() throws
Exception {
+ final ConcurrentStartConnector connector = new
ConcurrentStartConnector();
+ final CoordinatedConnectorStateTransition stateTransition = new
CoordinatedConnectorStateTransition();
+ final StandardConnectorNode connectorNode =
createConnectorNode(connector, stateTransition);
+ connector.blockValidation();
+
+ try (final ExecutorService executor = Executors.newFixedThreadPool(2))
{
+ final CompletableFuture<Future<Void>> firstRequest =
CompletableFuture.supplyAsync(() -> connectorNode.start(scheduler), executor);
+ final CompletableFuture<Future<Void>> secondRequest =
CompletableFuture.supplyAsync(() -> connectorNode.start(scheduler), executor);
+
+ assertTrue(connector.awaitValidationRequests(5, TimeUnit.SECONDS));
+ stateTransition.coordinateNextStateReads(2);
+ connector.releaseValidation();
+
+ final Future<Void> firstStart = firstRequest.get(5,
TimeUnit.SECONDS);
+ final Future<Void> secondStart = secondRequest.get(5,
TimeUnit.SECONDS);
+ firstStart.get(5, TimeUnit.SECONDS);
+ secondStart.get(5, TimeUnit.SECONDS);
+ }
+
+ assertEquals(1, connector.getStartInvocations());
+ assertEquals(ConnectorState.RUNNING, connectorNode.getCurrentState());
+ }
+
@Test
public void testVerifyCanDeleteWhenStopped() throws FlowUpdateException {
final StandardConnectorNode connectorNode = createConnectorNode();
@@ -1365,8 +1394,20 @@ public class TestStandardConnectorNode {
return createConnectorNode(connector, defaultSecretsManager);
}
+ private StandardConnectorNode createConnectorNode(final Connector
connector, final ConnectorStateTransition stateTransition) throws
FlowUpdateException {
+ final SecretsManager defaultSecretsManager =
mock(SecretsManager.class);
+ when(defaultSecretsManager.getAllSecrets()).thenReturn(List.of());
+
when(defaultSecretsManager.getSecrets(anySet())).thenReturn(Collections.emptyMap());
+ when(defaultSecretsManager.getSecrets(anySet(),
anyBoolean())).thenReturn(Collections.emptyMap());
+ return createConnectorNode(connector, defaultSecretsManager,
stateTransition);
+ }
+
private StandardConnectorNode createConnectorNode(final Connector
connector, final SecretsManager initializedSecretsManager) throws
FlowUpdateException {
- final ConnectorStateTransition stateTransition = new
StandardConnectorStateTransition("TestConnectorNode");
+ return createConnectorNode(connector, initializedSecretsManager, new
StandardConnectorStateTransition("TestConnectorNode"));
+ }
+
+ private StandardConnectorNode createConnectorNode(final Connector
connector, final SecretsManager initializedSecretsManager,
+ final ConnectorStateTransition stateTransition) throws
FlowUpdateException {
final ConnectorValidationTrigger validationTrigger = new
SynchronousConnectorValidationTrigger();
final StandardConnectorNode node = new StandardConnectorNode(
"test-connector-id",
@@ -1392,6 +1433,88 @@ public class TestStandardConnectorNode {
return node;
}
+ private static class ConcurrentStartConnector extends SleepingConnector {
+ private final CountDownLatch validationRequests = new
CountDownLatch(2);
+ private final CountDownLatch validationRelease = new CountDownLatch(1);
+ private final AtomicInteger startInvocations = new AtomicInteger();
+ private volatile boolean validationBlocked;
+
+ private ConcurrentStartConnector() {
+ super(Duration.ZERO);
+ }
+
+ @Override
+ public List<ValidationResult> validate(final FlowContext flowContext,
final ConnectorValidationContext connectorValidationContext) {
+ if (validationBlocked) {
+ validationRequests.countDown();
+ try {
+ validationRelease.await();
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Interrupted while waiting
to release validation", e);
+ }
+ }
+
+ return List.of();
+ }
+
+ @Override
+ public void start(final FlowContext activeContext) {
+ startInvocations.incrementAndGet();
+ }
+
+ private void blockValidation() {
+ validationBlocked = true;
+ }
+
+ private boolean awaitValidationRequests(final long timeout, final
TimeUnit timeUnit) throws InterruptedException {
+ return validationRequests.await(timeout, timeUnit);
+ }
+
+ private void releaseValidation() {
+ validationRelease.countDown();
+ }
+
+ private int getStartInvocations() {
+ return startInvocations.get();
+ }
+ }
+
+ private static class CoordinatedConnectorStateTransition extends
StandardConnectorStateTransition {
+ private volatile CountDownLatch coordinatedStateReads;
+
+ private CoordinatedConnectorStateTransition() {
+ super("TestConnectorNode");
+ }
+
+ @Override
+ public ConnectorState getCurrentState() {
+ final CountDownLatch stateReads = coordinatedStateReads;
+ final ConnectorState currentState = super.getCurrentState();
+ if (stateReads != null) {
+ stateReads.countDown();
+ try {
+ if (!stateReads.await(5, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("Timed out waiting for
coordinated state reads");
+ }
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Interrupted while waiting
for coordinated state reads", e);
+ } finally {
+ if (stateReads.getCount() == 0) {
+ coordinatedStateReads = null;
+ }
+ }
+ }
+
+ return currentState;
+ }
+
+ private void coordinateNextStateReads(final int count) {
+ coordinatedStateReads = new CountDownLatch(count);
+ }
+ }
+
private static class SynchronousConnectorValidationTrigger implements
ConnectorValidationTrigger {
@Override
public void triggerAsync(final ConnectorNode connector) {
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorStateTransition.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorStateTransition.java
new file mode 100644
index 00000000000..aaf29f01676
--- /dev/null
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorStateTransition.java
@@ -0,0 +1,38 @@
+/*
+ * 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.nifi.components.connector;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CompletableFuture;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TestStandardConnectorStateTransition {
+
+ @Test
+ void testAddPendingStartFutureWhenRunningCompletesFuture() {
+ final StandardConnectorStateTransition stateTransition = new
StandardConnectorStateTransition("Test Connector");
+ stateTransition.setCurrentState(ConnectorState.RUNNING);
+ final CompletableFuture<Void> startFuture = new CompletableFuture<>();
+
+ stateTransition.addPendingStartFuture(startFuture);
+
+ assertTrue(startFuture.isDone());
+ }
+}