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 a80a072c311 NIFI-16017: Do not allow node to Connect to cluster if it 
has Connector in wrong Troubleshooting state (#11334)
a80a072c311 is described below

commit a80a072c311571f16d7fc3d1eaccc342ac200424
Author: Mark Payne <[email protected]>
AuthorDate: Fri Jun 12 23:25:28 2026 -0400

    NIFI-16017: Do not allow node to Connect to cluster if it has Connector in 
wrong Troubleshooting state (#11334)
---
 .../serialization/VersionedFlowSynchronizer.java   |  33 +++++
 .../VersionedFlowSynchronizerTest.java             |  86 ++++++++++++-
 .../ClusteredConnectorTroubleshootingIT.java       | 133 +++++++++++++++++++++
 3 files changed, 249 insertions(+), 3 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/serialization/VersionedFlowSynchronizer.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/serialization/VersionedFlowSynchronizer.java
index 2f0d7cb333e..29310984eb8 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/serialization/VersionedFlowSynchronizer.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/serialization/VersionedFlowSynchronizer.java
@@ -28,6 +28,7 @@ import org.apache.nifi.cluster.protocol.DataFlow;
 import org.apache.nifi.cluster.protocol.StandardDataFlow;
 import org.apache.nifi.components.connector.ConnectorNode;
 import org.apache.nifi.components.connector.ConnectorRepository;
+import org.apache.nifi.components.connector.ConnectorState;
 import org.apache.nifi.components.connector.ConnectorSyncMode;
 import org.apache.nifi.components.connector.ConnectorSyncResult;
 import org.apache.nifi.components.validation.ValidationStatus;
@@ -60,6 +61,7 @@ import org.apache.nifi.flow.ScheduledState;
 import org.apache.nifi.flow.VersionedAsset;
 import org.apache.nifi.flow.VersionedComponent;
 import org.apache.nifi.flow.VersionedConnector;
+import org.apache.nifi.flow.VersionedConnectorState;
 import org.apache.nifi.flow.VersionedControllerService;
 import org.apache.nifi.flow.VersionedExternalFlow;
 import org.apache.nifi.flow.VersionedFlowAnalysisRule;
@@ -223,6 +225,12 @@ public class VersionedFlowSynchronizer implements 
FlowSynchronizer {
                 verifyNoConnectionsWithDataRemoved(existingDataFlow, 
proposedFlow, controller, flowComparison);
             }
 
+            // Ensure that no Connector is in Troubleshooting mode locally 
while the proposed flow has it in a
+            // non-Troubleshooting state, or vice versa. Reconciling such a 
mismatch would require either dropping the
+            // user's in-progress Troubleshooting flow or forcing the rest of 
the cluster into Troubleshooting, so the
+            // node must not join until the mismatch is resolved.
+            verifyConnectorTroubleshootingStatesMatch(proposedFlow, 
controller);
+
             synchronizeFlow(controller, existingDataFlow, proposedFlow, 
affectedComponents);
         } finally {
             if (!existingFlowEmpty) {
@@ -271,6 +279,31 @@ public class VersionedFlowSynchronizer implements 
FlowSynchronizer {
         }
     }
 
+    private void verifyConnectorTroubleshootingStatesMatch(final DataFlow 
proposedFlow, final FlowController controller) {
+        final VersionedDataflow proposedDataflow = 
proposedFlow.getVersionedDataflow();
+        if (proposedDataflow == null || proposedDataflow.getConnectors() == 
null) {
+            return;
+        }
+
+        final ConnectorRepository connectorRepository = 
controller.getConnectorRepository();
+        for (final VersionedConnector proposedConnector : 
proposedDataflow.getConnectors()) {
+            final ConnectorNode localConnector = 
connectorRepository.getConnector(proposedConnector.getInstanceIdentifier(), 
ConnectorSyncMode.LOCAL_ONLY);
+            if (localConnector == null) {
+                continue;
+            }
+
+            final boolean localTroubleshooting = 
localConnector.getCurrentState() == ConnectorState.TROUBLESHOOTING;
+            final boolean proposedTroubleshooting = 
proposedConnector.getScheduledState() == 
VersionedConnectorState.TROUBLESHOOTING;
+            if (localTroubleshooting != proposedTroubleshooting) {
+                final String troubleshootingDescription = localTroubleshooting
+                    ? "in Troubleshooting mode on this node but is not in 
Troubleshooting mode in the cluster flow"
+                    : "not in Troubleshooting mode on this node but is in 
Troubleshooting mode in the cluster flow";
+                throw new UninheritableFlowException("Proposed flow is not 
inheritable by the flow controller because " + localConnector + " is " + 
troubleshootingDescription
+                    + ". Exit or enter Troubleshooting mode for this Connector 
so that its state matches the cluster before joining.");
+            }
+        }
+    }
+
     private void mapCompatibleBundles(final DataFlow proposedFlow, final 
ExtensionManager extensionManager) {
         final Set<String> missingComponentIds = 
proposedFlow.getMissingComponents();
         final VersionedDataflow dataflow = proposedFlow.getVersionedDataflow();
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/serialization/VersionedFlowSynchronizerTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/serialization/VersionedFlowSynchronizerTest.java
index 91ea1a87471..96a1432301f 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/serialization/VersionedFlowSynchronizerTest.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/serialization/VersionedFlowSynchronizerTest.java
@@ -20,6 +20,7 @@ import org.apache.nifi.cluster.protocol.DataFlow;
 import org.apache.nifi.components.PropertyDescriptor;
 import org.apache.nifi.components.connector.ConnectorNode;
 import org.apache.nifi.components.connector.ConnectorRepository;
+import org.apache.nifi.components.connector.ConnectorState;
 import org.apache.nifi.components.connector.ConnectorSyncMode;
 import org.apache.nifi.components.connector.ConnectorSyncResult;
 import org.apache.nifi.controller.FlowController;
@@ -53,6 +54,8 @@ import org.mockito.ArgumentCaptor;
 import org.mockito.InOrder;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
 
 import java.io.File;
 import java.nio.charset.StandardCharsets;
@@ -64,6 +67,7 @@ import java.util.UUID;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 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.eq;
 import static org.mockito.Mockito.inOrder;
@@ -73,6 +77,7 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
 class VersionedFlowSynchronizerTest {
     private static final String FLOW_CONFIGURATION = "flow.json.gz";
 
@@ -367,7 +372,7 @@ class VersionedFlowSynchronizerTest {
         proposedConnector.setScheduledState(VersionedConnectorState.ENABLED);
 
         final ConnectorNode orphanConnector = mock(ConnectorNode.class);
-        
org.mockito.Mockito.lenient().when(orphanConnector.getIdentifier()).thenReturn("orphan-connector-id");
+        
when(orphanConnector.getIdentifier()).thenReturn("orphan-connector-id");
         when(connectorRepository.stopConnector(orphanConnector))
                 
.thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null));
 
@@ -396,7 +401,7 @@ class VersionedFlowSynchronizerTest {
         final ConnectorRepository connectorRepository = 
mock(ConnectorRepository.class);
 
         final ConnectorNode existingConnector = mock(ConnectorNode.class);
-        
org.mockito.Mockito.lenient().when(existingConnector.getIdentifier()).thenReturn(connectorId);
+        when(existingConnector.getIdentifier()).thenReturn(connectorId);
 
         final VersionedConnector versionedConnector = new VersionedConnector();
         versionedConnector.setInstanceIdentifier(connectorId);
@@ -436,7 +441,7 @@ class VersionedFlowSynchronizerTest {
         proposedConnector.setScheduledState(VersionedConnectorState.ENABLED);
 
         final ConnectorNode orphanConnector = mock(ConnectorNode.class);
-        
org.mockito.Mockito.lenient().when(orphanConnector.getIdentifier()).thenReturn("orphan-connector-id");
+        
when(orphanConnector.getIdentifier()).thenReturn("orphan-connector-id");
 
         final java.util.concurrent.CompletableFuture<Void> failedFuture = new 
java.util.concurrent.CompletableFuture<>();
         failedFuture.completeExceptionally(new RuntimeException("Stop 
failed"));
@@ -457,4 +462,79 @@ class VersionedFlowSynchronizerTest {
         verify(connectorRepository).syncConnector(proposedConnector);
         verify(orphanConnector).markInvalid(eq("Flow Synchronization 
Failure"), any());
     }
+
+    @Test
+    void testSyncFailsWhenLocalConnectorTroubleshootingButClusterIsNot() {
+        setRootGroup();
+
+        final String connectorId = UUID.randomUUID().toString();
+        final VersionedConnector proposedConnector = 
createVersionedConnector(connectorId, VersionedConnectorState.RUNNING);
+
+        final ConnectorRepository connectorRepository = 
mock(ConnectorRepository.class);
+        final ConnectorNode localConnector = mock(ConnectorNode.class);
+        
when(localConnector.getCurrentState()).thenReturn(ConnectorState.TROUBLESHOOTING);
+        when(connectorRepository.getConnector(connectorId, 
ConnectorSyncMode.LOCAL_ONLY)).thenReturn(localConnector);
+
+        setFlowController(connectorRepository);
+        
when(versionedDataflow.getConnectors()).thenReturn(List.of(proposedConnector));
+
+        final UninheritableFlowException thrown = 
assertThrows(UninheritableFlowException.class, () ->
+                versionedFlowSynchronizer.sync(flowController, dataFlow, 
flowService, BundleUpdateStrategy.USE_SPECIFIED_OR_GHOST));
+        assertTrue(thrown.getMessage().contains("Troubleshooting"));
+        verify(connectorRepository, never()).syncConnector(any());
+    }
+
+    @Test
+    void testSyncFailsWhenClusterConnectorTroubleshootingButLocalIsNot() {
+        setRootGroup();
+
+        final String connectorId = UUID.randomUUID().toString();
+        final VersionedConnector proposedConnector = 
createVersionedConnector(connectorId, VersionedConnectorState.TROUBLESHOOTING);
+
+        final ConnectorRepository connectorRepository = 
mock(ConnectorRepository.class);
+        final ConnectorNode localConnector = mock(ConnectorNode.class);
+        
when(localConnector.getCurrentState()).thenReturn(ConnectorState.RUNNING);
+        when(connectorRepository.getConnector(connectorId, 
ConnectorSyncMode.LOCAL_ONLY)).thenReturn(localConnector);
+
+        setFlowController(connectorRepository);
+        
when(versionedDataflow.getConnectors()).thenReturn(List.of(proposedConnector));
+
+        assertThrows(UninheritableFlowException.class, () ->
+                versionedFlowSynchronizer.sync(flowController, dataFlow, 
flowService, BundleUpdateStrategy.USE_SPECIFIED_OR_GHOST));
+        verify(connectorRepository, never()).syncConnector(any());
+    }
+
+    @Test
+    void testSyncProceedsWhenConnectorTroubleshootingStateMatchesCluster() {
+        setRootGroup();
+
+        final String connectorId = UUID.randomUUID().toString();
+        final VersionedConnector proposedConnector = 
createVersionedConnector(connectorId, VersionedConnectorState.TROUBLESHOOTING);
+
+        final ConnectorRepository connectorRepository = 
mock(ConnectorRepository.class);
+        final ConnectorNode localConnector = mock(ConnectorNode.class);
+        
when(localConnector.getCurrentState()).thenReturn(ConnectorState.TROUBLESHOOTING);
+        when(connectorRepository.getConnector(connectorId, 
ConnectorSyncMode.LOCAL_ONLY)).thenReturn(localConnector);
+
+        final ConnectorNode syncedNode = mock(ConnectorNode.class);
+        when(connectorRepository.syncConnector(proposedConnector))
+                
.thenReturn(ConnectorSyncResult.syncedConfigUnchanged(syncedNode, 
VersionedConnectorState.TROUBLESHOOTING));
+
+        setFlowController(connectorRepository);
+        
when(versionedDataflow.getConnectors()).thenReturn(List.of(proposedConnector));
+
+        versionedFlowSynchronizer.sync(flowController, dataFlow, flowService, 
BundleUpdateStrategy.USE_SPECIFIED_OR_GHOST);
+
+        verify(connectorRepository).syncConnector(proposedConnector);
+    }
+
+    private VersionedConnector createVersionedConnector(final String 
connectorId, final VersionedConnectorState scheduledState) {
+        final VersionedConnector versionedConnector = new VersionedConnector();
+        versionedConnector.setInstanceIdentifier(connectorId);
+        versionedConnector.setName("Test Connector");
+        versionedConnector.setType("org.apache.nifi.connectors.TestConnector");
+        versionedConnector.setBundle(CORE_BUNDLE);
+        versionedConnector.setScheduledState(scheduledState);
+        return versionedConnector;
+    }
 }
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ClusteredConnectorTroubleshootingIT.java
 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ClusteredConnectorTroubleshootingIT.java
index 99d50f0bf06..e7a7ac850be 100644
--- 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ClusteredConnectorTroubleshootingIT.java
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ClusteredConnectorTroubleshootingIT.java
@@ -17,6 +17,7 @@
 
 package org.apache.nifi.tests.system.connectors;
 
+import org.apache.nifi.cluster.coordination.node.NodeConnectionState;
 import org.apache.nifi.components.connector.ConnectorState;
 import org.apache.nifi.tests.system.NiFiInstanceFactory;
 import org.apache.nifi.toolkit.client.NiFiClientException;
@@ -76,6 +77,138 @@ public class ClusteredConnectorTroubleshootingIT extends 
ConnectorTroubleshootin
         assertConnectorState(connectorId, ConnectorState.STOPPED);
     }
 
+    /**
+     * Verifies that a node refuses to rejoin the cluster when it holds a 
RUNNING Connector in Troubleshooting locally
+     * while the cluster's authoritative state for that Connector is not 
Troubleshooting.
+     *
+     * <p>The sequence is:</p>
+     * <ol>
+     *   <li>Start the Connector so the cluster's authoritative state for it 
is RUNNING.</li>
+     *   <li>Disconnect Node 2.</li>
+     *   <li>Issue a node-local request against the disconnected Node 2 to 
enter Troubleshooting, so only Node 2 is in
+     *       Troubleshooting while the connected coordinator (Node 1) keeps 
the Connector RUNNING.</li>
+     *   <li>Reconnect Node 2.</li>
+     * </ol>
+     *
+     * <p>Node 2 has the Connector in Troubleshooting locally but the 
cluster's authoritative state is not Troubleshooting,
+     * so the troubleshooting-state mismatch makes the proposed cluster flow 
uninheritable. Node 2 returns to DISCONNECTED
+     * and keeps its own local Troubleshooting flow rather than adopting the 
cluster's flow.</p>
+     */
+    @Test
+    public void 
testNodeCannotRejoinWhenRunningConnectorWentTroubleshootingWhileDisconnected()
+            throws NiFiClientException, IOException, InterruptedException {
+
+        final ConnectorEntity connector = 
getClientUtil().createConnector("ComponentLifecycleConnector");
+        final String connectorId = connector.getId();
+
+        getClientUtil().applyConnectorUpdate(connector);
+        getClientUtil().waitForValidConnector(connectorId);
+
+        getClientUtil().startConnector(connectorId);
+        assertConnectorState(connectorId, ConnectorState.RUNNING);
+
+        disconnectNode(2);
+        enterTroubleshootingOnDisconnectedNode2(connectorId);
+
+        reconnectNode(2);
+
+        assertNode2FailsToRejoinAndKeepsLocalState(connectorId, 
ConnectorState.TROUBLESHOOTING);
+    }
+
+    /**
+     * Companion to {@link 
#testNodeCannotRejoinWhenRunningConnectorWentTroubleshootingWhileDisconnected()}
 for a STOPPED
+     * Connector. The cluster's authoritative state is STOPPED while Node 2 is 
locally in Troubleshooting. As with the
+     * RUNNING case, the troubleshooting-state mismatch makes the cluster flow 
uninheritable, so Node 2 must refuse to
+     * join and remain DISCONNECTED with its local Troubleshooting flow intact.
+     */
+    @Test
+    public void 
testNodeCannotRejoinWhenStoppedConnectorWentTroubleshootingWhileDisconnected()
+            throws NiFiClientException, IOException, InterruptedException {
+
+        final ConnectorEntity connector = 
getClientUtil().createConnector("ComponentLifecycleConnector");
+        final String connectorId = connector.getId();
+
+        getClientUtil().applyConnectorUpdate(connector);
+        getClientUtil().waitForValidConnector(connectorId);
+        assertConnectorState(connectorId, ConnectorState.STOPPED);
+
+        disconnectNode(2);
+        enterTroubleshootingOnDisconnectedNode2(connectorId);
+
+        reconnectNode(2);
+
+        assertNode2FailsToRejoinAndKeepsLocalState(connectorId, 
ConnectorState.TROUBLESHOOTING);
+    }
+
+    /**
+     * Companion to {@link 
#testNodeCannotRejoinWhenStoppedConnectorWentTroubleshootingWhileDisconnected()}
 for the
+     * reverse mismatch: Node 2 is not in Troubleshooting locally while the 
cluster's authoritative state is
+     * Troubleshooting.
+     *
+     * <p>The Connector is left STOPPED, then Node 2 is disconnected and the 
Connector is taken into Troubleshooting on the
+     * remaining coordinator, so the cluster's authoritative state is 
Troubleshooting while Node 2 stays STOPPED locally.
+     * When Node 2 reconnects, its local non-Troubleshooting state conflicts 
with the cluster's Troubleshooting state, so
+     * the cluster flow is uninheritable and Node 2 must refuse to join, 
remaining DISCONNECTED with its local STOPPED flow
+     * intact.</p>
+     */
+    @Test
+    public void 
testNodeCannotRejoinWhenClusterEnteredTroubleshootingWhileNodeDisconnected()
+            throws NiFiClientException, IOException, InterruptedException {
+
+        final ConnectorEntity connector = 
getClientUtil().createConnector("ComponentLifecycleConnector");
+        final String connectorId = connector.getId();
+
+        getClientUtil().applyConnectorUpdate(connector);
+        getClientUtil().waitForValidConnector(connectorId);
+        assertConnectorState(connectorId, ConnectorState.STOPPED);
+
+        disconnectNode(2);
+
+        getClientUtil().enterTroubleshooting(connectorId);
+        assertConnectorState(connectorId, ConnectorState.TROUBLESHOOTING);
+
+        reconnectNode(2);
+
+        assertNode2FailsToRejoinAndKeepsLocalState(connectorId, 
ConnectorState.STOPPED);
+    }
+
+    private void assertNode2FailsToRejoinAndKeepsLocalState(final String 
connectorId, final ConnectorState expectedNode2State)
+            throws InterruptedException, NiFiClientException, IOException {
+        // The reconnect must be rejected because Node 2's local 
Troubleshooting state does not match the cluster, so Node 2
+        // returns to DISCONNECTED rather than reaching CONNECTED.
+        waitForNodeState(2, NodeConnectionState.DISCONNECTED);
+
+        // Only the coordinator remains connected.
+        assertEquals(1, 
getNifiClient().getFlowClient().getClusterSummary().getClusterSummary().getConnectedNodeCount().intValue());
+
+        // Node 2 kept its own local flow rather than adopting the cluster's 
conflicting flow.
+        waitForNode2ConnectorState(connectorId, expectedNode2State);
+    }
+
+    private void enterTroubleshootingOnDisconnectedNode2(final String 
connectorId) throws NiFiClientException, IOException, InterruptedException {
+        switchClientToNode(2);
+        try {
+            final ConnectorEntity node2Connector = 
getNifiClient().getConnectorClient(DO_NOT_REPLICATE).getConnector(connectorId);
+            node2Connector.setDisconnectedNodeAcknowledged(true);
+            
getNifiClient().getConnectorClient(DO_NOT_REPLICATE).enterTroubleshooting(node2Connector);
+
+            waitFor(() -> ConnectorState.TROUBLESHOOTING.name().equals(
+                    
getNifiClient().getConnectorClient(DO_NOT_REPLICATE).getConnector(connectorId).getComponent().getState()));
+        } finally {
+            switchClientToNode(1);
+        }
+    }
+
+    private void waitForNode2ConnectorState(final String connectorId, final 
ConnectorState expected) throws InterruptedException {
+        switchClientToNode(2);
+        try {
+            waitFor(() -> expected.name().equals(
+                    
getNifiClient().getConnectorClient(DO_NOT_REPLICATE).getConnector(connectorId).getComponent().getState()));
+        } finally {
+            switchClientToNode(1);
+        }
+    }
+
     private void assertConnectorState(final String connectorId, final 
ConnectorState expected) throws NiFiClientException, IOException {
         final ConnectorEntity entity = 
getNifiClient().getConnectorClient().getConnector(connectorId);
         assertEquals(expected.name(), entity.getComponent().getState());

Reply via email to