This is an automated email from the ASF dual-hosted git repository.

exceptionfactory 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 c7c745e8c8d NIFI-16064 Preserve local Public Port Names on updates 
from Version Control (#11384)
c7c745e8c8d is described below

commit c7c745e8c8dcc7518b9d03720c85879cf29281be
Author: Pierre Villard <[email protected]>
AuthorDate: Wed Jul 22 22:12:30 2026 +0200

    NIFI-16064 Preserve local Public Port Names on updates from Version Control 
(#11384)
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../StandardVersionedComponentSynchronizer.java    |  18 +++-
 .../apache/nifi/groups/StandardProcessGroup.java   |   5 +-
 ...StandardVersionedComponentSynchronizerTest.java | 102 +++++++++++++++++++++
 .../nifi/util/TestFlowDifferenceFilters.java       |  22 +++++
 .../nifi/groups/FlowSynchronizationOptions.java    |  22 +++++
 .../apache/nifi/web/StandardNiFiServiceFacade.java |   4 +
 6 files changed, 170 insertions(+), 3 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java
index 06442bd4757..7315d8d441d 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java
@@ -343,6 +343,14 @@ public class StandardVersionedComponentSynchronizer 
implements VersionedComponen
                 continue;
             }
 
+            // When updating from version control, preserve a local rename of 
a public port (an input/output port that allows remote
+            // access) instead of reverting it to the registry-defined name. 
Without this, the name change is treated as an update and the user's
+            // local name is overwritten. Only the version-control update path 
opts in via preservePublicPortNames; cluster reconnection and startup
+            // leave it false so the node still adopts the incoming flow's 
port names.
+            if (syncOptions.isPreservePublicPortNames() && 
FlowDifferenceFilters.isPublicPortNameChange(diff)) {
+                continue;
+            }
+
             // If this update adds a new Controller Service, then we need to 
check if the service already exists at a higher level
             // and if so compare our VersionedControllerService to the 
existing service.
             if (diff.getDifferenceType() == DifferenceType.COMPONENT_ADDED) {
@@ -1051,7 +1059,10 @@ public class StandardVersionedComponentSynchronizer 
implements VersionedComponen
                 LOG.info("Added {} to {}", added, group);
             } else if 
(updatedVersionedComponentIds.contains(proposedPort.getIdentifier())) {
                 final String temporaryName = 
generateTemporaryPortName(proposedPort);
-                proposedPortFinalNames.put(port, proposedPort.getName());
+                // When the port is updated for any reason, preserve the local 
name of a public port instead of overwriting it with the
+                // registry-defined name (the port may be in the update set 
because of another difference such as a comment change).
+                final String finalName = 
syncOptions.isPreservePublicPortNames() && port instanceof PublicPort ? 
port.getName() : proposedPort.getName();
+                proposedPortFinalNames.put(port, finalName);
                 updatePort(port, proposedPort, temporaryName);
                 LOG.info("Updated {}", port);
             } else {
@@ -1073,7 +1084,10 @@ public class StandardVersionedComponentSynchronizer 
implements VersionedComponen
                 LOG.info("Added {} to {}", added, group);
             } else if 
(updatedVersionedComponentIds.contains(proposedPort.getIdentifier())) {
                 final String temporaryName = 
generateTemporaryPortName(proposedPort);
-                proposedPortFinalNames.put(port, proposedPort.getName());
+                // When the port is updated for any reason, preserve the local 
name of a public port instead of overwriting it with the
+                // registry-defined name (the port may be in the update set 
because of another difference such as a comment change).
+                final String finalName = 
syncOptions.isPreservePublicPortNames() && port instanceof PublicPort ? 
port.getName() : proposedPort.getName();
+                proposedPortFinalNames.put(port, finalName);
                 updatePort(port, proposedPort, temporaryName);
                 LOG.info("Updated {}", port);
             } else {
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
index 6ef66c0ea0d..1aa7d4c5959 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
@@ -3959,7 +3959,10 @@ public final class StandardProcessGroup implements 
ProcessGroup {
             .ignoreLocalModifications(!verifyNotDirty)
             .updateDescendantVersionedFlows(updateDescendantVersionedFlows)
             .updateGroupSettings(updateSettings)
-            .updateRpgUrls(false);
+            .updateRpgUrls(false)
+            // A user may rename a public port locally (e.g. to resolve a name 
collision when reusing a versioned group). That local name must
+            // survive a version-control update rather than being reverted to 
the name stored in the registry.
+            .preservePublicPortNames(true);
         // Connectors should not have encrypted values copied from versioned 
flow. However we do need to decrypt parameter references.
         if (getConnectorIdentifier().isPresent()) {
             flowSynchronizationBuilder.propertyDecryptor(value -> value);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizerTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizerTest.java
index 9ad6c27646e..0c2f28f7aed 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizerTest.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizerTest.java
@@ -78,6 +78,7 @@ import 
org.apache.nifi.parameter.StandardParameterContextManager;
 import org.apache.nifi.processor.ProcessContext;
 import org.apache.nifi.processor.Relationship;
 import org.apache.nifi.registry.flow.mapping.FlowMappingOptions;
+import org.apache.nifi.remote.PublicPort;
 import org.apache.nifi.reporting.InitializationException;
 import org.apache.nifi.scheduling.ExecutionNode;
 import org.apache.nifi.scheduling.SchedulingStrategy;
@@ -935,6 +936,56 @@ public class StandardVersionedComponentSynchronizerTest {
         verify(inputPort).setName("Input");
     }
 
+    @Test
+    public void testPublicInputPortLocalNamePreservedOnVersionControlUpdate() {
+        // With preservePublicPortNames on (the version-control update path), 
a public input port whose only difference
+        // from the registry flow is a local rename must keep its local name; 
the port is not even flagged for update.
+        final ProcessGroup processGroup = createMockProcessGroup();
+        final PublicPort publicPort = 
createMappablePublicInputPort(processGroup, "port-vid", "Local Renamed", null);
+        
when(processGroup.getInputPorts()).thenReturn(Collections.singleton(publicPort));
+        
when(flowManager.getPublicInputPort(anyString())).thenReturn(Optional.empty());
+
+        final VersionedExternalFlow externalFlow = 
singlePublicInputPortFlow(processGroup, "port-vid", "Registry Name", null);
+
+        assertDoesNotThrow(() -> synchronizer.synchronize(processGroup, 
externalFlow, preservePublicPortNamesOptions(true)));
+
+        verify(publicPort, never()).setName(anyString());
+    }
+
+    @Test
+    public void testPublicInputPortNameAdoptedWhenNotPreserving() {
+        // With preservePublicPortNames off (cluster reconnection / startup), 
the incoming flow's port name must still be adopted.
+        final ProcessGroup processGroup = createMockProcessGroup();
+        final PublicPort publicPort = 
createMappablePublicInputPort(processGroup, "port-vid", "Local Renamed", null);
+        
when(processGroup.getInputPorts()).thenReturn(Collections.singleton(publicPort));
+        
when(flowManager.getPublicInputPort(anyString())).thenReturn(Optional.empty());
+
+        final VersionedExternalFlow externalFlow = 
singlePublicInputPortFlow(processGroup, "port-vid", "Registry Name", null);
+
+        assertDoesNotThrow(() -> synchronizer.synchronize(processGroup, 
externalFlow, preservePublicPortNamesOptions(false)));
+
+        verify(publicPort).setName("Registry Name");
+    }
+
+    @Test
+    public void 
testPublicInputPortLocalNamePreservedWhenPortUpdatedForOtherReasons() {
+        // Multi-change case: the port lands in the update set because of 
another difference (comments), but with the
+        // flag on the local name must still be preserved even though the port 
is updated.
+        final ProcessGroup processGroup = createMockProcessGroup();
+        final PublicPort publicPort = 
createMappablePublicInputPort(processGroup, "port-vid", "Local Renamed", "old 
comments");
+        
when(processGroup.getInputPorts()).thenReturn(Collections.singleton(publicPort));
+        
when(flowManager.getPublicInputPort(anyString())).thenReturn(Optional.empty());
+
+        final VersionedExternalFlow externalFlow = 
singlePublicInputPortFlow(processGroup, "port-vid", "Registry Name", "new 
comments");
+
+        assertDoesNotThrow(() -> synchronizer.synchronize(processGroup, 
externalFlow, preservePublicPortNamesOptions(true)));
+
+        // The comment change is applied, but the local name wins over the 
registry name.
+        verify(publicPort).setComments("new comments");
+        verify(publicPort).setName("Local Renamed");
+        verify(publicPort, never()).setName("Registry Name");
+    }
+
     @Test
     public void testRemoveOutputPortFailsIfIncomingConnection() {
         createMockConnection(processorA, outputPort, group);
@@ -1674,6 +1725,57 @@ public class StandardVersionedComponentSynchronizerTest {
         return versionedPort;
     }
 
+    private FlowSynchronizationOptions preservePublicPortNamesOptions(final 
boolean preserve) {
+        return new FlowSynchronizationOptions.Builder()
+            .componentIdGenerator(componentIdGenerator)
+            .componentComparisonIdLookup(VersionedComponent::getIdentifier)
+            .componentScheduler(componentScheduler)
+            .scheduledStateChangeListener(scheduledStateChangeListener)
+            .preservePublicPortNames(preserve)
+            .build();
+    }
+
+    private PublicPort createMappablePublicInputPort(final ProcessGroup 
processGroup, final String versionedId, final String localName, final String 
comments) {
+        final String groupId = processGroup.getIdentifier();
+        final PublicPort port = Mockito.mock(PublicPort.class);
+        when(port.getIdentifier()).thenReturn(UUID.randomUUID().toString());
+        when(port.getProcessGroupIdentifier()).thenReturn(groupId);
+        
when(port.getVersionedComponentId()).thenReturn(Optional.of(versionedId));
+        when(port.getName()).thenReturn(localName);
+        when(port.getComments()).thenReturn(comments);
+        when(port.getMaxConcurrentTasks()).thenReturn(1);
+        when(port.getPosition()).thenReturn(new 
org.apache.nifi.connectable.Position(0, 0));
+        when(port.getConnectableType()).thenReturn(ConnectableType.INPUT_PORT);
+        
when(port.getScheduledState()).thenReturn(org.apache.nifi.controller.ScheduledState.STOPPED);
+        when(port.isRunning()).thenReturn(false);
+        when(port.getProcessGroup()).thenReturn(processGroup);
+        return port;
+    }
+
+    // Builds a proposed flow whose only public input port matches the live 
port by versioned id, differing by the given name
+    // (and optionally comments). ENABLED scheduled state and the other fields 
match what a STOPPED live port maps to, so the
+    // only differences are the ones under test.
+    private VersionedExternalFlow singlePublicInputPortFlow(final ProcessGroup 
processGroup, final String versionedId, final String name, final String 
comments) {
+        final VersionedPort proposedPort = new VersionedPort();
+        proposedPort.setIdentifier(versionedId);
+        proposedPort.setInstanceIdentifier(versionedId);
+        proposedPort.setName(name);
+        proposedPort.setComments(comments);
+        proposedPort.setScheduledState(ScheduledState.ENABLED);
+        proposedPort.setComponentType(ComponentType.INPUT_PORT);
+        proposedPort.setPosition(new Position(0D, 0D));
+        proposedPort.setConcurrentlySchedulableTaskCount(1);
+        proposedPort.setAllowRemoteAccess(Boolean.TRUE);
+
+        final VersionedProcessGroup versionedGroup = new 
VersionedProcessGroup();
+        versionedGroup.setIdentifier(processGroup.getIdentifier());
+        versionedGroup.setInputPorts(Set.of(proposedPort));
+
+        final VersionedExternalFlow externalFlow = new VersionedExternalFlow();
+        externalFlow.setFlowContents(versionedGroup);
+        return externalFlow;
+    }
+
     private void assertSensitivePropertyDecrypted(final ComponentNode 
componentNode) {
         verify(componentNode).setProperties(propertiesCaptor.capture(), 
eq(true), eq(Collections.emptySet()));
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/util/TestFlowDifferenceFilters.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/util/TestFlowDifferenceFilters.java
index dfb672b0c05..81c45668eef 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/util/TestFlowDifferenceFilters.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/util/TestFlowDifferenceFilters.java
@@ -949,6 +949,28 @@ public class TestFlowDifferenceFilters {
         
assertTrue(FlowDifferenceFilters.isComponentUpdateRequired(scheduledStateDiff, 
null, flowManager));
     }
 
+    @Test
+    public void testIsComponentUpdateRequiredForPublicPortNameChange() {
+        // A public-port name change must still be reported as "update 
required" by the shared filter. The preservation of a
+        // local public-port name is scoped to the synchronizer (gated by 
FlowSynchronizationOptions.preservePublicPortNames) and to the
+        // affected-components calculation (which applies 
FILTER_PUBLIC_PORT_NAME_CHANGES), so the core semantics of this shared, static
+        // method must remain unchanged - otherwise cluster reconnection and 
startup would stop preserving the incoming flow's port names.
+        final FlowManager flowManager = Mockito.mock(FlowManager.class);
+
+        final VersionedPort portA = new VersionedPort();
+        portA.setAllowRemoteAccess(Boolean.TRUE);
+        final VersionedPort portB = new VersionedPort();
+        portB.setAllowRemoteAccess(Boolean.TRUE);
+
+        final StandardFlowDifference nameChange = new StandardFlowDifference(
+                DifferenceType.NAME_CHANGED, portA, portB, "Original Name", 
"Renamed", "");
+
+        assertTrue(FlowDifferenceFilters.isComponentUpdateRequired(nameChange, 
null, flowManager));
+
+        // The dedicated predicate, on the other hand, excludes the 
public-port name change so opt-in callers can preserve the local name.
+        
assertFalse(FlowDifferenceFilters.FILTER_PUBLIC_PORT_NAME_CHANGES.test(nameChange));
+    }
+
     @DynamicProperty(name = "Dynamic Property", value = "Value", description = 
"Allows dynamic properties")
     private static class DynamicAnnotationProcessor extends AbstractProcessor {
         @Override
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/FlowSynchronizationOptions.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/FlowSynchronizationOptions.java
index d2e8624b04d..a9e1014d45f 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/FlowSynchronizationOptions.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/FlowSynchronizationOptions.java
@@ -31,6 +31,7 @@ public class FlowSynchronizationOptions {
     private final boolean updateSettings;
     private final boolean updateDescendantVersionedFlows;
     private final boolean updateRpgUrls;
+    private final boolean preservePublicPortNames;
     private final Duration componentStopTimeout;
     private final ComponentStopTimeoutAction timeoutAction;
     private final ScheduledStateChangeListener scheduledStateChangeListener;
@@ -45,6 +46,7 @@ public class FlowSynchronizationOptions {
         this.updateSettings = builder.updateSettings;
         this.updateDescendantVersionedFlows = 
builder.updateDescendantVersionedFlows;
         this.updateRpgUrls = builder.updateRpgUrls;
+        this.preservePublicPortNames = builder.preservePublicPortNames;
         this.componentStopTimeout = builder.componentStopTimeout;
         this.timeoutAction = builder.timeoutAction;
         this.scheduledStateChangeListener = 
builder.scheduledStateChangeListener;
@@ -79,6 +81,10 @@ public class FlowSynchronizationOptions {
         return updateRpgUrls;
     }
 
+    public boolean isPreservePublicPortNames() {
+        return preservePublicPortNames;
+    }
+
     public PropertyDecryptor getPropertyDecryptor() {
         return propertyDecryptor;
     }
@@ -107,6 +113,7 @@ public class FlowSynchronizationOptions {
         private boolean updateSettings = true;
         private boolean updateDescendantVersionedFlows = true;
         private boolean updateRpgUrls = false;
+        private boolean preservePublicPortNames = false;
         private ScheduledStateChangeListener scheduledStateChangeListener;
         private PropertyDecryptor propertyDecryptor = value -> value;
         private Duration componentStopTimeout = Duration.ofSeconds(30);
@@ -188,6 +195,20 @@ public class FlowSynchronizationOptions {
             return this;
         }
 
+        /**
+         * Specifies whether the local name of a public port (an input/output 
port that allows remote access) should be preserved when synchronizing,
+         * rather than being overwritten with the name from the proposed flow. 
This is used for registry version-control updates, where a user may have
+         * renamed a public port locally to avoid a name collision, and that 
local name must survive the update. It should remain false for cluster
+         * reconnection and startup flow inheritance, where the node must 
adopt the incoming flow's port names verbatim.
+         *
+         * @param preservePublicPortNames whether to preserve local 
public-port names
+         * @return the builder
+         */
+        public Builder preservePublicPortNames(final boolean 
preservePublicPortNames) {
+            this.preservePublicPortNames = preservePublicPortNames;
+            return this;
+        }
+
         /**
          * Specifies the decryptor to use for sensitive properties
          *
@@ -265,6 +286,7 @@ public class FlowSynchronizationOptions {
             builder.updateSettings = options.isUpdateSettings();
             builder.updateDescendantVersionedFlows = 
options.isUpdateDescendantVersionedFlows();
             builder.updateRpgUrls = options.isUpdateRpgUrls();
+            builder.preservePublicPortNames = 
options.isPreservePublicPortNames();
             builder.propertyDecryptor = options.getPropertyDecryptor();
             builder.componentStopTimeout = options.getComponentStopTimeout();
             builder.timeoutAction = options.getComponentStopTimeoutAction();
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
index 5ae791ae3b3..aca6949c908 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
@@ -6919,6 +6919,10 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
                 
.filter(FlowDifferenceFilters.FILTER_ADDED_REMOVED_REMOTE_PORTS)
                 .filter(difference -> difference.getComponentA() != null) // a 
difference that would not affect a local component
                 .filter(diff -> 
FlowDifferenceFilters.isComponentUpdateRequired(diff, 
proposedFlow.getContents(), flowManager))
+                // A local rename of a public port is preserved during a 
version-control update (it is not overwritten with the
+                // registry name), so the port must not be reported as 
affected/stopped for that name change. Applied unconditionally here because
+                // this affected-components calculation serves only the 
version-control update path.
+                .filter(FlowDifferenceFilters.FILTER_PUBLIC_PORT_NAME_CHANGES)
                 .filter(diff -> 
!FlowDifferenceFilters.isLocalScheduleStateChange(diff))
                 .map(difference -> {
                     final VersionedComponent localComponent = 
difference.getComponentA();

Reply via email to